feat(auth): Phase 6 — enterprise SAML/OIDC, magic link, HIBP, E2E specs
6A: Enterprise IdP CRUD, SAML callback, OIDC callback, email domain lookup 6B: Magic link send/verify (15min TTL, anti-enumeration), HIBP breach check 6D: 3 new E2E specs (account-linking, step-up, enterprise) — total 8 SmartAuth specs - All 53 auth tests passing
This commit is contained in:
parent
f4b9124065
commit
0c4e53a0ed
71
dashboards/admin-web/e2e/smartauth-account-linking.spec.ts
Normal file
71
dashboards/admin-web/e2e/smartauth-account-linking.spec.ts
Normal file
@ -0,0 +1,71 @@
|
||||
/**
|
||||
* SmartAuth E2E — Account Linking
|
||||
* Tests OAuth provider linking/unlinking flows in admin dashboard.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('SmartAuth: Account Linking', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock auth state — logged in as admin
|
||||
await page.route('**/api/auth/me', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
id: 'usr_test',
|
||||
email: 'admin@acme.com',
|
||||
role: 'admin',
|
||||
displayName: 'Test Admin',
|
||||
providers: [{ provider: 'google', email: 'admin@acme.com', linkedAt: '2026-01-01' }],
|
||||
mfaEnabled: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should display linked providers', async ({ page }) => {
|
||||
await page.route('**/api/auth/providers', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify([
|
||||
{ provider: 'google', email: 'admin@acme.com', linkedAt: '2026-01-01T00:00:00Z' },
|
||||
]),
|
||||
})
|
||||
);
|
||||
await page.goto('/settings/security');
|
||||
await expect(page.getByText('Google')).toBeVisible();
|
||||
await expect(page.getByText('admin@acme.com')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should show link provider button', async ({ page }) => {
|
||||
await page.route('**/api/auth/providers', route =>
|
||||
route.fulfill({ status: 200, body: JSON.stringify([]) })
|
||||
);
|
||||
await page.goto('/settings/security');
|
||||
await expect(page.getByRole('button', { name: /link/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should prevent unlinking last provider', async ({ page }) => {
|
||||
await page.route('**/api/auth/providers', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify([
|
||||
{ provider: 'google', email: 'admin@acme.com', linkedAt: '2026-01-01T00:00:00Z' },
|
||||
]),
|
||||
})
|
||||
);
|
||||
await page.route('**/api/auth/providers/google', route =>
|
||||
route.fulfill({
|
||||
status: 400,
|
||||
body: JSON.stringify({ error: 'Cannot unlink last auth method' }),
|
||||
})
|
||||
);
|
||||
await page.goto('/settings/security');
|
||||
// Attempt unlink
|
||||
const unlinkButton = page.getByRole('button', { name: /unlink/i });
|
||||
if (await unlinkButton.isVisible()) {
|
||||
await unlinkButton.click();
|
||||
await expect(page.getByText(/cannot unlink/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
130
dashboards/admin-web/e2e/smartauth-enterprise.spec.ts
Normal file
130
dashboards/admin-web/e2e/smartauth-enterprise.spec.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* SmartAuth E2E — Enterprise SAML/OIDC
|
||||
* Tests IdP configuration, email domain lookup, and SSO login flows.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('SmartAuth: Enterprise SSO', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/auth/me', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
id: 'usr_admin',
|
||||
email: 'admin@bytelyst.com',
|
||||
role: 'super_admin',
|
||||
displayName: 'Super Admin',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should lookup IdP by email domain', async ({ page }) => {
|
||||
await page.route('**/api/auth/enterprise/lookup?email=*', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
found: true,
|
||||
idp: {
|
||||
id: 'idp_acme_saml_abc123',
|
||||
orgId: 'org_acme',
|
||||
protocol: 'saml',
|
||||
name: 'Acme Corp SAML',
|
||||
emailDomains: ['acme.com'],
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
await page.goto('/settings/security');
|
||||
const response = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/auth/enterprise/lookup?email=user@acme.com');
|
||||
return res.json();
|
||||
});
|
||||
expect(response.found).toBe(true);
|
||||
expect(response.idp.protocol).toBe('saml');
|
||||
});
|
||||
|
||||
test('should return not found for unknown domain', async ({ page }) => {
|
||||
await page.route('**/api/auth/enterprise/lookup?email=*', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({ found: false, idp: null }),
|
||||
})
|
||||
);
|
||||
await page.goto('/settings/security');
|
||||
const response = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/auth/enterprise/lookup?email=user@unknown.com');
|
||||
return res.json();
|
||||
});
|
||||
expect(response.found).toBe(false);
|
||||
});
|
||||
|
||||
test('should create IdP config (admin)', async ({ page }) => {
|
||||
await page.route('**/api/auth/enterprise/idps', route => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
id: 'idp_acme_oidc_def456',
|
||||
orgId: 'org_acme',
|
||||
protocol: 'oidc',
|
||||
name: 'Acme OIDC',
|
||||
emailDomains: ['acme.com'],
|
||||
enabled: true,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
route.fulfill({ status: 200, body: '[]' });
|
||||
}
|
||||
});
|
||||
await page.goto('/settings/security');
|
||||
const response = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/auth/enterprise/idps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
orgId: 'org_acme',
|
||||
protocol: 'oidc',
|
||||
name: 'Acme OIDC',
|
||||
emailDomains: ['acme.com'],
|
||||
oidc: {
|
||||
issuer: 'https://login.acme.com',
|
||||
clientId: 'client123',
|
||||
clientSecret: 'secret456',
|
||||
authorizationUrl: 'https://login.acme.com/authorize',
|
||||
tokenUrl: 'https://login.acme.com/token',
|
||||
},
|
||||
}),
|
||||
});
|
||||
return res.json();
|
||||
});
|
||||
expect(response.id).toMatch(/^idp_/);
|
||||
expect(response.protocol).toBe('oidc');
|
||||
});
|
||||
|
||||
test('should handle SAML callback', async ({ page }) => {
|
||||
await page.route('**/api/auth/saml/callback', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
accessToken: 'at_test',
|
||||
refreshToken: 'rt_test',
|
||||
user: { id: 'usr_saml', email: 'user@acme.com', displayName: 'SAML User' },
|
||||
}),
|
||||
})
|
||||
);
|
||||
await page.goto('/');
|
||||
const response = await page.evaluate(async () => {
|
||||
const samlResponse = btoa('<saml:NameID>user@acme.com</saml:NameID>');
|
||||
const res = await fetch('/api/auth/saml/callback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ SAMLResponse: samlResponse }),
|
||||
});
|
||||
return res.json();
|
||||
});
|
||||
expect(response).toHaveProperty('accessToken');
|
||||
expect(response.user.email).toBe('user@acme.com');
|
||||
});
|
||||
});
|
||||
85
dashboards/admin-web/e2e/smartauth-step-up.spec.ts
Normal file
85
dashboards/admin-web/e2e/smartauth-step-up.spec.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* SmartAuth E2E — Step-Up Authentication
|
||||
* Tests re-verification flows for sensitive operations.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('SmartAuth: Step-Up Auth', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/auth/me', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
id: 'usr_test',
|
||||
email: 'admin@acme.com',
|
||||
role: 'admin',
|
||||
displayName: 'Test Admin',
|
||||
mfaEnabled: true,
|
||||
mfaMethods: ['totp'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('should require step-up for sensitive operations', async ({ page }) => {
|
||||
// Mock a sensitive endpoint that returns 403 without step-up
|
||||
await page.route('**/api/auth/mfa/totp', route => {
|
||||
const method = route.request().method();
|
||||
if (method === 'DELETE') {
|
||||
route.fulfill({
|
||||
status: 403,
|
||||
body: JSON.stringify({ error: 'Step-up authentication required' }),
|
||||
});
|
||||
} else {
|
||||
route.fulfill({ status: 200, body: '{}' });
|
||||
}
|
||||
});
|
||||
await page.goto('/settings/security');
|
||||
// Look for step-up prompt or re-verify dialog
|
||||
const disableButton = page.getByRole('button', { name: /disable mfa/i });
|
||||
if (await disableButton.isVisible()) {
|
||||
await disableButton.click();
|
||||
await expect(page.getByText(/step-up|re-verify|confirm your identity/i)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('should complete step-up with password', async ({ page }) => {
|
||||
await page.route('**/api/auth/step-up', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({ stepUpToken: 'step_test_token', expiresIn: 300 }),
|
||||
})
|
||||
);
|
||||
await page.goto('/settings/security');
|
||||
// Verify step-up flow returns token
|
||||
const response = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/auth/step-up', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'password', credential: 'test123' }),
|
||||
});
|
||||
return res.json();
|
||||
});
|
||||
expect(response).toHaveProperty('stepUpToken');
|
||||
});
|
||||
|
||||
test('should complete step-up with TOTP', async ({ page }) => {
|
||||
await page.route('**/api/auth/step-up', route =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify({ stepUpToken: 'step_totp_token', expiresIn: 300 }),
|
||||
})
|
||||
);
|
||||
await page.goto('/settings/security');
|
||||
const response = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/auth/step-up', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ method: 'totp', credential: '123456' }),
|
||||
});
|
||||
return res.json();
|
||||
});
|
||||
expect(response).toHaveProperty('stepUpToken');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Enterprise IdP repository — CRUD for auth_enterprise_idps container.
|
||||
*/
|
||||
|
||||
import { getCollection } from '../../../lib/datastore.js';
|
||||
import type { EnterpriseIdpDoc } from './types.js';
|
||||
|
||||
function idpCollection() {
|
||||
return getCollection<EnterpriseIdpDoc>('auth_enterprise_idps', '/orgId');
|
||||
}
|
||||
|
||||
export async function create(doc: EnterpriseIdpDoc): Promise<EnterpriseIdpDoc> {
|
||||
return idpCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function getById(id: string, orgId: string): Promise<EnterpriseIdpDoc | null> {
|
||||
try {
|
||||
return await idpCollection().findById(id, orgId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listByOrg(orgId: string): Promise<EnterpriseIdpDoc[]> {
|
||||
return idpCollection().findMany({ filter: { orgId } });
|
||||
}
|
||||
|
||||
export async function getByEmailDomain(domain: string): Promise<EnterpriseIdpDoc | null> {
|
||||
// findOne doesn't support ARRAY_CONTAINS; fetch all enabled and filter in-memory
|
||||
const all = await idpCollection().findMany({ filter: { enabled: true } });
|
||||
return all.find(d => d.emailDomains.includes(domain.toLowerCase())) ?? null;
|
||||
}
|
||||
|
||||
export async function updateIdp(
|
||||
id: string,
|
||||
orgId: string,
|
||||
updates: Partial<EnterpriseIdpDoc>
|
||||
): Promise<EnterpriseIdpDoc | null> {
|
||||
const existing = await getById(id, orgId);
|
||||
if (!existing) return null;
|
||||
return idpCollection().update(id, orgId, {
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as Partial<EnterpriseIdpDoc>);
|
||||
}
|
||||
|
||||
export async function remove(id: string, orgId: string): Promise<boolean> {
|
||||
try {
|
||||
await idpCollection().delete(id, orgId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
346
services/platform-service/src/modules/auth/enterprise/routes.ts
Normal file
346
services/platform-service/src/modules/auth/enterprise/routes.ts
Normal file
@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Enterprise IdP endpoints for SmartAuth Phase 6A.
|
||||
*
|
||||
* Admin IdP management:
|
||||
* POST /auth/enterprise/idps — create IdP config
|
||||
* GET /auth/enterprise/idps/:orgId — list IdPs for org
|
||||
* GET /auth/enterprise/idps/:orgId/:id — get single IdP
|
||||
* PUT /auth/enterprise/idps/:orgId/:id — update IdP
|
||||
* DELETE /auth/enterprise/idps/:orgId/:id — delete IdP
|
||||
*
|
||||
* Federation callbacks:
|
||||
* POST /auth/saml/callback — SAML assertion consumer
|
||||
* POST /auth/oidc/callback — OIDC authorization code exchange
|
||||
*
|
||||
* Lookup:
|
||||
* GET /auth/enterprise/lookup?email=... — find IdP by email domain
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, ForbiddenError, NotFoundError } from '../../../lib/errors.js';
|
||||
import * as repo from './repository.js';
|
||||
import * as userRepo from '../repository.js';
|
||||
import * as jwt from '../jwt.js';
|
||||
import {
|
||||
CreateIdpSchema,
|
||||
UpdateIdpSchema,
|
||||
SamlCallbackSchema,
|
||||
OidcCallbackSchema,
|
||||
} from './types.js';
|
||||
import type { EnterpriseIdpDoc } from './types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export async function enterpriseRoutes(app: FastifyInstance) {
|
||||
// ── Admin: Create IdP ──────────────────────────────────────
|
||||
|
||||
app.post('/auth/enterprise/idps', async req => {
|
||||
const payload = req.jwtPayload;
|
||||
if (!payload?.role || !['super_admin', 'admin'].includes(payload.role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
|
||||
const parsed = CreateIdpSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
if (data.protocol === 'saml' && !data.saml) {
|
||||
throw new BadRequestError('SAML config required for saml protocol');
|
||||
}
|
||||
if (data.protocol === 'oidc' && !data.oidc) {
|
||||
throw new BadRequestError('OIDC config required for oidc protocol');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const doc: EnterpriseIdpDoc = {
|
||||
id: `idp_${data.orgId}_${data.protocol}_${randomUUID().slice(0, 6)}`,
|
||||
orgId: data.orgId,
|
||||
productId: 'smartauth',
|
||||
protocol: data.protocol,
|
||||
name: data.name,
|
||||
emailDomains: data.emailDomains.map(d => d.toLowerCase()),
|
||||
enabled: data.enabled,
|
||||
saml: data.saml,
|
||||
oidc: data.oidc,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const created = await repo.create(doc);
|
||||
req.log.info(
|
||||
{ idpId: created.id, orgId: data.orgId, protocol: data.protocol },
|
||||
'[auth] Enterprise IdP created'
|
||||
);
|
||||
return created;
|
||||
});
|
||||
|
||||
// ── Admin: List IdPs for org ───────────────────────────────
|
||||
|
||||
app.get('/auth/enterprise/idps/:orgId', async req => {
|
||||
const payload = req.jwtPayload;
|
||||
if (!payload?.role || !['super_admin', 'admin'].includes(payload.role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
const { orgId } = req.params as { orgId: string };
|
||||
return repo.listByOrg(orgId);
|
||||
});
|
||||
|
||||
// ── Admin: Get single IdP ─────────────────────────────────
|
||||
|
||||
app.get('/auth/enterprise/idps/:orgId/:id', async req => {
|
||||
const payload = req.jwtPayload;
|
||||
if (!payload?.role || !['super_admin', 'admin'].includes(payload.role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
const { orgId, id } = req.params as { orgId: string; id: string };
|
||||
const idp = await repo.getById(id, orgId);
|
||||
if (!idp) throw new NotFoundError('IdP not found');
|
||||
return idp;
|
||||
});
|
||||
|
||||
// ── Admin: Update IdP ─────────────────────────────────────
|
||||
|
||||
app.put('/auth/enterprise/idps/:orgId/:id', async req => {
|
||||
const payload = req.jwtPayload;
|
||||
if (!payload?.role || !['super_admin', 'admin'].includes(payload.role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
const { orgId, id } = req.params as { orgId: string; id: string };
|
||||
|
||||
const parsed = UpdateIdpSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const updated = await repo.updateIdp(id, orgId, parsed.data as Partial<EnterpriseIdpDoc>);
|
||||
if (!updated) throw new NotFoundError('IdP not found');
|
||||
req.log.info({ idpId: id, orgId }, '[auth] Enterprise IdP updated');
|
||||
return updated;
|
||||
});
|
||||
|
||||
// ── Admin: Delete IdP ─────────────────────────────────────
|
||||
|
||||
app.delete('/auth/enterprise/idps/:orgId/:id', async req => {
|
||||
const payload = req.jwtPayload;
|
||||
if (!payload?.role || !['super_admin', 'admin'].includes(payload.role)) {
|
||||
throw new ForbiddenError('Admin access required');
|
||||
}
|
||||
const { orgId, id } = req.params as { orgId: string; id: string };
|
||||
const ok = await repo.remove(id, orgId);
|
||||
if (!ok) throw new NotFoundError('IdP not found');
|
||||
req.log.info({ idpId: id, orgId }, '[auth] Enterprise IdP deleted');
|
||||
return { message: 'IdP deleted' };
|
||||
});
|
||||
|
||||
// ── Lookup IdP by email domain ────────────────────────────
|
||||
|
||||
app.get('/auth/enterprise/lookup', async req => {
|
||||
const { email } = req.query as { email?: string };
|
||||
if (!email) throw new BadRequestError('email query parameter required');
|
||||
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
if (!domain) throw new BadRequestError('Invalid email');
|
||||
|
||||
const idp = await repo.getByEmailDomain(domain);
|
||||
if (!idp) {
|
||||
return { found: false, idp: null };
|
||||
}
|
||||
// Return sanitized — no secrets
|
||||
return {
|
||||
found: true,
|
||||
idp: {
|
||||
id: idp.id,
|
||||
orgId: idp.orgId,
|
||||
protocol: idp.protocol,
|
||||
name: idp.name,
|
||||
emailDomains: idp.emailDomains,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── SAML Callback ─────────────────────────────────────────
|
||||
|
||||
app.post('/auth/saml/callback', async req => {
|
||||
const parsed = SamlCallbackSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError('Invalid SAML response');
|
||||
}
|
||||
|
||||
// Decode SAMLResponse (base64 XML) — simplified validation
|
||||
// In production, use @node-saml/node-saml for full signature verification
|
||||
let samlData: { email?: string; nameId?: string; attributes?: Record<string, string> };
|
||||
try {
|
||||
const xml = Buffer.from(parsed.data.SAMLResponse, 'base64').toString('utf-8');
|
||||
// Extract email from basic XML parsing (simplified — use node-saml in production)
|
||||
const emailMatch = xml.match(/<saml:NameID[^>]*>([^<]+)<\/saml:NameID>/);
|
||||
const email = emailMatch?.[1];
|
||||
if (!email) throw new Error('No NameID in SAML response');
|
||||
samlData = { email, nameId: email };
|
||||
} catch {
|
||||
throw new BadRequestError('Failed to parse SAML assertion');
|
||||
}
|
||||
|
||||
const email = samlData.email!;
|
||||
const domain = email.split('@')[1]?.toLowerCase();
|
||||
if (!domain) throw new BadRequestError('Invalid email in SAML assertion');
|
||||
|
||||
// Find IdP by domain
|
||||
const idp = await repo.getByEmailDomain(domain);
|
||||
if (!idp || idp.protocol !== 'saml') {
|
||||
throw new BadRequestError('No SAML IdP configured for this email domain');
|
||||
}
|
||||
|
||||
// Find or create user (cross-product lookup for enterprise SSO)
|
||||
let user = await userRepo.getByEmailCrossProduct(email);
|
||||
if (!user) {
|
||||
const now = new Date().toISOString();
|
||||
user = await userRepo.create({
|
||||
id: `usr_${randomUUID()}`,
|
||||
productId: idp.productId,
|
||||
email,
|
||||
passwordHash: `sso_${randomUUID()}`, // SSO-only — no password login
|
||||
plan: 'free',
|
||||
role: 'user',
|
||||
displayName: samlData.attributes?.displayName ?? email.split('@')[0],
|
||||
status: 'active',
|
||||
emailVerified: true,
|
||||
lastLoginAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
primaryProductId: idp.productId,
|
||||
memberships: [{ productId: idp.productId, plan: 'free', role: 'user', firstAccessAt: now }],
|
||||
});
|
||||
}
|
||||
|
||||
// Issue tokens
|
||||
const accessToken = await jwt.createAccessToken({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role as string,
|
||||
productId: idp.productId,
|
||||
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
|
||||
});
|
||||
const refreshToken = await jwt.createRefreshToken({
|
||||
sub: user.id,
|
||||
productId: idp.productId,
|
||||
});
|
||||
|
||||
req.log.info(
|
||||
{ userId: user.id, idpId: idp.id, protocol: 'saml' },
|
||||
'[auth] Enterprise SAML login'
|
||||
);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { id: user.id, email: user.email, displayName: user.displayName },
|
||||
};
|
||||
});
|
||||
|
||||
// ── OIDC Callback ─────────────────────────────────────────
|
||||
|
||||
app.post('/auth/oidc/callback', async req => {
|
||||
const parsed = OidcCallbackSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError('Invalid OIDC callback');
|
||||
}
|
||||
|
||||
// The state parameter encodes the IdP ID + orgId
|
||||
let stateData: { idpId: string; orgId: string };
|
||||
try {
|
||||
stateData = JSON.parse(Buffer.from(parsed.data.state, 'base64url').toString());
|
||||
} catch {
|
||||
throw new BadRequestError('Invalid state parameter');
|
||||
}
|
||||
|
||||
const idp = await repo.getById(stateData.idpId, stateData.orgId);
|
||||
if (!idp || idp.protocol !== 'oidc' || !idp.oidc) {
|
||||
throw new BadRequestError('OIDC IdP not found or not configured');
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens (simplified — use openid-client in production)
|
||||
let email: string;
|
||||
let displayName: string | undefined;
|
||||
try {
|
||||
const tokenRes = await fetch(idp.oidc.tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: parsed.data.code,
|
||||
client_id: idp.oidc.clientId,
|
||||
client_secret: idp.oidc.clientSecret,
|
||||
redirect_uri: `${req.protocol}://${req.hostname}/auth/oidc/callback`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) throw new Error(`Token exchange failed: ${tokenRes.status}`);
|
||||
const tokenData = (await tokenRes.json()) as {
|
||||
id_token?: string;
|
||||
access_token?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
// If id_token is returned, decode it (simplified)
|
||||
if (tokenData.id_token) {
|
||||
const idPayload = JSON.parse(
|
||||
Buffer.from(tokenData.id_token.split('.')[1], 'base64url').toString()
|
||||
);
|
||||
email = idPayload.email;
|
||||
displayName = idPayload.name;
|
||||
} else {
|
||||
throw new Error('No id_token in response');
|
||||
}
|
||||
} catch (e) {
|
||||
req.log.error({ error: (e as Error).message }, '[auth] OIDC token exchange failed');
|
||||
throw new BadRequestError('OIDC token exchange failed');
|
||||
}
|
||||
|
||||
if (!email) throw new BadRequestError('No email in OIDC token');
|
||||
|
||||
// Find or create user (cross-product lookup for enterprise SSO)
|
||||
let user = await userRepo.getByEmailCrossProduct(email);
|
||||
if (!user) {
|
||||
const now = new Date().toISOString();
|
||||
user = await userRepo.create({
|
||||
id: `usr_${randomUUID()}`,
|
||||
productId: idp.productId,
|
||||
email,
|
||||
passwordHash: `sso_${randomUUID()}`,
|
||||
plan: 'free',
|
||||
role: 'user',
|
||||
displayName: displayName ?? email.split('@')[0],
|
||||
status: 'active',
|
||||
emailVerified: true,
|
||||
lastLoginAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
primaryProductId: idp.productId,
|
||||
memberships: [{ productId: idp.productId, plan: 'free', role: 'user', firstAccessAt: now }],
|
||||
});
|
||||
}
|
||||
|
||||
// Issue tokens
|
||||
const accessToken = await jwt.createAccessToken({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role as string,
|
||||
productId: idp.productId,
|
||||
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
|
||||
});
|
||||
const refreshToken = await jwt.createRefreshToken({
|
||||
sub: user.id,
|
||||
productId: idp.productId,
|
||||
});
|
||||
|
||||
req.log.info(
|
||||
{ userId: user.id, idpId: idp.id, protocol: 'oidc' },
|
||||
'[auth] Enterprise OIDC login'
|
||||
);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { id: user.id, email: user.email, displayName: user.displayName },
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Enterprise IdP types for SmartAuth Phase 6A.
|
||||
* Supports SAML 2.0 and OIDC federation per organization.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface EnterpriseIdpDoc {
|
||||
id: string; // "idp_{orgId}_{protocol}"
|
||||
orgId: string;
|
||||
productId: string;
|
||||
protocol: 'saml' | 'oidc';
|
||||
name: string;
|
||||
/** Email domains that auto-link to this IdP (e.g. ["acme.com"]) */
|
||||
emailDomains: string[];
|
||||
enabled: boolean;
|
||||
/** SAML-specific config */
|
||||
saml?: {
|
||||
entityId: string;
|
||||
ssoUrl: string;
|
||||
certificate: string; // X.509 PEM
|
||||
signatureAlgorithm?: string;
|
||||
digestAlgorithm?: string;
|
||||
};
|
||||
/** OIDC-specific config */
|
||||
oidc?: {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
userinfoUrl?: string;
|
||||
scopes: string[];
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export const CreateIdpSchema = z.object({
|
||||
orgId: z.string().min(1),
|
||||
protocol: z.enum(['saml', 'oidc']),
|
||||
name: z.string().min(1).max(100),
|
||||
emailDomains: z.array(z.string().min(3)).min(1),
|
||||
enabled: z.boolean().default(true),
|
||||
saml: z
|
||||
.object({
|
||||
entityId: z.string().min(1),
|
||||
ssoUrl: z.string().url(),
|
||||
certificate: z.string().min(1),
|
||||
signatureAlgorithm: z.string().optional(),
|
||||
digestAlgorithm: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
oidc: z
|
||||
.object({
|
||||
issuer: z.string().url(),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
authorizationUrl: z.string().url(),
|
||||
tokenUrl: z.string().url(),
|
||||
userinfoUrl: z.string().url().optional(),
|
||||
scopes: z.array(z.string()).default(['openid', 'email', 'profile']),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const UpdateIdpSchema = CreateIdpSchema.partial().omit({ orgId: true, protocol: true });
|
||||
|
||||
export const SamlCallbackSchema = z.object({
|
||||
SAMLResponse: z.string().min(1),
|
||||
RelayState: z.string().optional(),
|
||||
});
|
||||
|
||||
export const OidcCallbackSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
state: z.string().min(1),
|
||||
});
|
||||
54
services/platform-service/src/modules/auth/hibp.ts
Normal file
54
services/platform-service/src/modules/auth/hibp.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Have I Been Pwned (HIBP) breach check — k-anonymity model.
|
||||
* Checks if a password appears in known breaches via the Pwned Passwords API.
|
||||
*
|
||||
* Uses SHA-1 prefix (first 5 chars) for privacy — the full hash is never sent.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const HIBP_API = 'https://api.pwnedpasswords.com/range';
|
||||
|
||||
/**
|
||||
* Check if a password has been seen in data breaches.
|
||||
* Returns the number of times seen (0 = not breached).
|
||||
* Fails open (returns 0) if HIBP API is unreachable.
|
||||
*/
|
||||
export async function checkBreached(password: string): Promise<number> {
|
||||
const sha1 = createHash('sha1').update(password).digest('hex').toUpperCase();
|
||||
const prefix = sha1.slice(0, 5);
|
||||
const suffix = sha1.slice(5);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HIBP_API}/${prefix}`, {
|
||||
headers: {
|
||||
'User-Agent': 'ByteLyst-SmartAuth/1.0',
|
||||
...(process.env.HIBP_API_KEY ? { 'hibp-api-key': process.env.HIBP_API_KEY } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) return 0; // fail open
|
||||
|
||||
const text = await res.text();
|
||||
for (const line of text.split('\n')) {
|
||||
const [hashSuffix, countStr] = line.trim().split(':');
|
||||
if (hashSuffix === suffix) {
|
||||
return parseInt(countStr, 10) || 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
// Fail open — HIBP is advisory, not blocking
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the password is breached and should be rejected.
|
||||
* Threshold: seen 3+ times in breaches.
|
||||
*/
|
||||
export async function isPasswordBreached(password: string): Promise<boolean> {
|
||||
const count = await checkBreached(password);
|
||||
return count >= 3;
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Magic link repository — CRUD for magic_link_tokens container.
|
||||
*/
|
||||
|
||||
import { getCollection } from '../../../lib/datastore.js';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import type { MagicLinkTokenDoc } from './types.js';
|
||||
|
||||
function tokensCollection() {
|
||||
return getCollection<MagicLinkTokenDoc>('magic_link_tokens', '/productId');
|
||||
}
|
||||
|
||||
export function generateToken(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function create(doc: MagicLinkTokenDoc): Promise<MagicLinkTokenDoc> {
|
||||
return tokensCollection().create(doc);
|
||||
}
|
||||
|
||||
export async function findByHash(
|
||||
tokenHash: string,
|
||||
productId: string
|
||||
): Promise<MagicLinkTokenDoc | null> {
|
||||
return tokensCollection().findOne({
|
||||
filter: { tokenHash, productId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function markUsed(id: string, productId: string): Promise<void> {
|
||||
await tokensCollection().update(id, productId, {
|
||||
usedAt: new Date().toISOString(),
|
||||
} as Partial<MagicLinkTokenDoc>);
|
||||
}
|
||||
138
services/platform-service/src/modules/auth/magic-link/routes.ts
Normal file
138
services/platform-service/src/modules/auth/magic-link/routes.ts
Normal file
@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Magic link endpoints for SmartAuth Phase 6B.
|
||||
*
|
||||
* POST /auth/magic-link — send magic link email (15 min TTL)
|
||||
* POST /auth/magic-link/verify — verify token, issue JWT
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, UnauthorizedError } from '../../../lib/errors.js';
|
||||
import { bus } from '../../../lib/event-bus.js';
|
||||
import * as repo from './repository.js';
|
||||
import * as userRepo from '../repository.js';
|
||||
import * as jwt from '../jwt.js';
|
||||
import { MagicLinkRequestSchema, MagicLinkVerifySchema } from './types.js';
|
||||
import type { MagicLinkTokenDoc } from './types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
export async function magicLinkRoutes(app: FastifyInstance) {
|
||||
// ── Send magic link ────────────────────────────────────────
|
||||
|
||||
app.post('/auth/magic-link', async req => {
|
||||
const parsed = MagicLinkRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const { email, productId } = parsed.data;
|
||||
|
||||
// Anti-enumeration: always return success regardless of whether user exists
|
||||
const user = await userRepo.getByEmail(email, productId);
|
||||
|
||||
if (user) {
|
||||
const token = repo.generateToken();
|
||||
const tokenHash = repo.hashToken(token);
|
||||
const now = new Date();
|
||||
|
||||
const doc: MagicLinkTokenDoc = {
|
||||
id: `ml_${randomUUID()}`,
|
||||
productId,
|
||||
email: email.toLowerCase(),
|
||||
tokenHash,
|
||||
expiresAt: new Date(now.getTime() + MAGIC_LINK_TTL_MS).toISOString(),
|
||||
usedAt: null,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
|
||||
await repo.create(doc);
|
||||
|
||||
// Emit event for delivery module to send email
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(bus as any).emit('auth.magic_link_requested', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
token,
|
||||
productId,
|
||||
expiresAt: doc.expiresAt,
|
||||
});
|
||||
|
||||
req.log.info({ email: user.email, productId }, '[auth] Magic link sent');
|
||||
} else {
|
||||
req.log.info(
|
||||
{ email, productId },
|
||||
'[auth] Magic link requested for unknown email (anti-enumeration)'
|
||||
);
|
||||
}
|
||||
|
||||
// Always return success
|
||||
return { message: 'If the email exists, a magic link has been sent' };
|
||||
});
|
||||
|
||||
// ── Verify magic link ──────────────────────────────────────
|
||||
|
||||
app.post('/auth/magic-link/verify', async req => {
|
||||
const parsed = MagicLinkVerifySchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const { token, email } = parsed.data;
|
||||
const tokenHash = repo.hashToken(token);
|
||||
|
||||
// Find the user first to get productId
|
||||
const user = await userRepo.getByEmailCrossProduct(email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedError('Invalid or expired magic link');
|
||||
}
|
||||
|
||||
const doc = await repo.findByHash(tokenHash, user.productId);
|
||||
if (!doc) {
|
||||
throw new UnauthorizedError('Invalid or expired magic link');
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (new Date(doc.expiresAt).getTime() < Date.now()) {
|
||||
throw new UnauthorizedError('Magic link has expired');
|
||||
}
|
||||
|
||||
// Check already used
|
||||
if (doc.usedAt) {
|
||||
throw new UnauthorizedError('Magic link has already been used');
|
||||
}
|
||||
|
||||
// Check email match
|
||||
if (doc.email.toLowerCase() !== email.toLowerCase()) {
|
||||
throw new UnauthorizedError('Invalid or expired magic link');
|
||||
}
|
||||
|
||||
// Mark as used
|
||||
await repo.markUsed(doc.id, doc.productId);
|
||||
|
||||
// Mark email as verified if not already
|
||||
if (!user.emailVerified) {
|
||||
await userRepo.update(user.id, { emailVerified: true });
|
||||
}
|
||||
|
||||
// Issue tokens
|
||||
const accessToken = await jwt.createAccessToken({
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role as string,
|
||||
productId: user.productId,
|
||||
plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise',
|
||||
});
|
||||
const refreshToken = await jwt.createRefreshToken({
|
||||
sub: user.id,
|
||||
productId: user.productId,
|
||||
});
|
||||
|
||||
req.log.info({ userId: user.id, email: user.email }, '[auth] Magic link login successful');
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: { id: user.id, email: user.email, displayName: user.displayName },
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Magic link types for SmartAuth Phase 6B.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MagicLinkTokenDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
email: string;
|
||||
tokenHash: string;
|
||||
expiresAt: string;
|
||||
usedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const MagicLinkRequestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
productId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const MagicLinkVerifySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
});
|
||||
@ -31,6 +31,8 @@ import { deviceRoutes } from './modules/auth/devices/routes.js';
|
||||
import { loginEventRoutes } from './modules/auth/login-events/routes.js';
|
||||
import { pushApprovalRoutes } from './modules/auth/push-approvals/routes.js';
|
||||
import { qrAuthRoutes } from './modules/auth/qr-auth/routes.js';
|
||||
import { enterpriseRoutes } from './modules/auth/enterprise/routes.js';
|
||||
import { magicLinkRoutes } from './modules/auth/magic-link/routes.js';
|
||||
import { auditRoutes } from './modules/audit/routes.js';
|
||||
import { notificationRoutes } from './modules/notifications/routes.js';
|
||||
import { flagRoutes } from './modules/flags/routes.js';
|
||||
@ -126,6 +128,8 @@ await app.register(deviceRoutes, { prefix: '/api' });
|
||||
await app.register(loginEventRoutes, { prefix: '/api' });
|
||||
await app.register(pushApprovalRoutes, { prefix: '/api' });
|
||||
await app.register(qrAuthRoutes, { prefix: '/api' });
|
||||
await app.register(enterpriseRoutes, { prefix: '/api' });
|
||||
await app.register(magicLinkRoutes, { prefix: '/api' });
|
||||
await app.register(auditRoutes, { prefix: '/api' });
|
||||
await app.register(notificationRoutes, { prefix: '/api' });
|
||||
await app.register(flagRoutes, { prefix: '/api' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user