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 { loginEventRoutes } from './modules/auth/login-events/routes.js';
|
||||||
import { pushApprovalRoutes } from './modules/auth/push-approvals/routes.js';
|
import { pushApprovalRoutes } from './modules/auth/push-approvals/routes.js';
|
||||||
import { qrAuthRoutes } from './modules/auth/qr-auth/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 { auditRoutes } from './modules/audit/routes.js';
|
||||||
import { notificationRoutes } from './modules/notifications/routes.js';
|
import { notificationRoutes } from './modules/notifications/routes.js';
|
||||||
import { flagRoutes } from './modules/flags/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(loginEventRoutes, { prefix: '/api' });
|
||||||
await app.register(pushApprovalRoutes, { prefix: '/api' });
|
await app.register(pushApprovalRoutes, { prefix: '/api' });
|
||||||
await app.register(qrAuthRoutes, { 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(auditRoutes, { prefix: '/api' });
|
||||||
await app.register(notificationRoutes, { prefix: '/api' });
|
await app.register(notificationRoutes, { prefix: '/api' });
|
||||||
await app.register(flagRoutes, { prefix: '/api' });
|
await app.register(flagRoutes, { prefix: '/api' });
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user