diff --git a/dashboards/admin-web/e2e/smartauth-account-linking.spec.ts b/dashboards/admin-web/e2e/smartauth-account-linking.spec.ts
new file mode 100644
index 00000000..1c535e67
--- /dev/null
+++ b/dashboards/admin-web/e2e/smartauth-account-linking.spec.ts
@@ -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();
+ }
+ });
+});
diff --git a/dashboards/admin-web/e2e/smartauth-enterprise.spec.ts b/dashboards/admin-web/e2e/smartauth-enterprise.spec.ts
new file mode 100644
index 00000000..959d2110
--- /dev/null
+++ b/dashboards/admin-web/e2e/smartauth-enterprise.spec.ts
@@ -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('user@acme.com');
+ 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');
+ });
+});
diff --git a/dashboards/admin-web/e2e/smartauth-step-up.spec.ts b/dashboards/admin-web/e2e/smartauth-step-up.spec.ts
new file mode 100644
index 00000000..8b7efbf3
--- /dev/null
+++ b/dashboards/admin-web/e2e/smartauth-step-up.spec.ts
@@ -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');
+ });
+});
diff --git a/services/platform-service/src/modules/auth/enterprise/repository.ts b/services/platform-service/src/modules/auth/enterprise/repository.ts
new file mode 100644
index 00000000..dedcc2d1
--- /dev/null
+++ b/services/platform-service/src/modules/auth/enterprise/repository.ts
@@ -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('auth_enterprise_idps', '/orgId');
+}
+
+export async function create(doc: EnterpriseIdpDoc): Promise {
+ return idpCollection().create(doc);
+}
+
+export async function getById(id: string, orgId: string): Promise {
+ try {
+ return await idpCollection().findById(id, orgId);
+ } catch {
+ return null;
+ }
+}
+
+export async function listByOrg(orgId: string): Promise {
+ return idpCollection().findMany({ filter: { orgId } });
+}
+
+export async function getByEmailDomain(domain: string): Promise {
+ // 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
+): Promise {
+ const existing = await getById(id, orgId);
+ if (!existing) return null;
+ return idpCollection().update(id, orgId, {
+ ...updates,
+ updatedAt: new Date().toISOString(),
+ } as Partial);
+}
+
+export async function remove(id: string, orgId: string): Promise {
+ try {
+ await idpCollection().delete(id, orgId);
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/services/platform-service/src/modules/auth/enterprise/routes.ts b/services/platform-service/src/modules/auth/enterprise/routes.ts
new file mode 100644
index 00000000..e62511bf
--- /dev/null
+++ b/services/platform-service/src/modules/auth/enterprise/routes.ts
@@ -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);
+ 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 };
+ 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>/);
+ 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 },
+ };
+ });
+}
diff --git a/services/platform-service/src/modules/auth/enterprise/types.ts b/services/platform-service/src/modules/auth/enterprise/types.ts
new file mode 100644
index 00000000..2fb12151
--- /dev/null
+++ b/services/platform-service/src/modules/auth/enterprise/types.ts
@@ -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),
+});
diff --git a/services/platform-service/src/modules/auth/hibp.ts b/services/platform-service/src/modules/auth/hibp.ts
new file mode 100644
index 00000000..009ee0e7
--- /dev/null
+++ b/services/platform-service/src/modules/auth/hibp.ts
@@ -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 {
+ 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 {
+ const count = await checkBreached(password);
+ return count >= 3;
+}
diff --git a/services/platform-service/src/modules/auth/magic-link/repository.ts b/services/platform-service/src/modules/auth/magic-link/repository.ts
new file mode 100644
index 00000000..99ae8051
--- /dev/null
+++ b/services/platform-service/src/modules/auth/magic-link/repository.ts
@@ -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('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 {
+ return tokensCollection().create(doc);
+}
+
+export async function findByHash(
+ tokenHash: string,
+ productId: string
+): Promise {
+ return tokensCollection().findOne({
+ filter: { tokenHash, productId },
+ });
+}
+
+export async function markUsed(id: string, productId: string): Promise {
+ await tokensCollection().update(id, productId, {
+ usedAt: new Date().toISOString(),
+ } as Partial);
+}
diff --git a/services/platform-service/src/modules/auth/magic-link/routes.ts b/services/platform-service/src/modules/auth/magic-link/routes.ts
new file mode 100644
index 00000000..363863dc
--- /dev/null
+++ b/services/platform-service/src/modules/auth/magic-link/routes.ts
@@ -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 },
+ };
+ });
+}
diff --git a/services/platform-service/src/modules/auth/magic-link/types.ts b/services/platform-service/src/modules/auth/magic-link/types.ts
new file mode 100644
index 00000000..c21d3dea
--- /dev/null
+++ b/services/platform-service/src/modules/auth/magic-link/types.ts
@@ -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(),
+});
diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts
index ee9e5651..14d085df 100644
--- a/services/platform-service/src/server.ts
+++ b/services/platform-service/src/server.ts
@@ -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' });