diff --git a/backend/security/package.json b/backend/security/package.json index 7982a3f0..53215eaf 100644 --- a/backend/security/package.json +++ b/backend/security/package.json @@ -41,6 +41,10 @@ "@types/pg": "^8.15.4", "@types/supertest": "^2.0.15", "@types/uuid": "^9.0.2", + "@types/js-yaml": "^4.0.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "js-yaml": "^4.1.0", "jest": "^29.7.0", "nodemon": "^3.0.1", "supertest": "^6.3.3", diff --git a/backend/security/tests/security-api/README.md b/backend/security/tests/security-api/README.md new file mode 100644 index 00000000..37cd9267 --- /dev/null +++ b/backend/security/tests/security-api/README.md @@ -0,0 +1,55 @@ +# AuthN contract-test suite (independent verification) + +Independent, spec-first verification of the FuzeFront Security API **AuthN** slice +against the **frozen** contract `packages/security/openapi.yaml` (PR #243). + +Authored by `test-engineer` — this suite verifies the implementation; it does not +implement it. A failing test against a real bug is a valid deliverable. + +## What it asserts + +- **Contract conformance** for every `/api/v1/security/*` AuthN endpoint: + session CRUD + `session/exchange`, social `start`/`callback` (302 semantics), + `signup`, `methods`, the `SessionResult` MFA-step-up discriminated union, the + MFA factor lifecycle (enroll → activate → remove, recovery codes), MFA login + step-up (challenge → verify), email/phone verification, and M2M tokens. + Response bodies are validated against the spec component schemas with Ajv + (OpenAPI 3.1 / JSON-Schema 2020-12). +- **Provider-swap proof** (`provider-swap.contract.test.ts`): the full path runs + through a second, independent `IdentityProvider` (`AltIdentityProvider`) with a + different token format/storage — proving no consumer-visible vendor coupling. +- **Boundary / neutrality** (`boundary.contract.test.ts`): no AuthN response, + redirect, or body references `auth.fuzefront.com` or names a vendor; social + `start` 302 targets a FuzeFront-owned (or Google) host only. +- **Fail-closed**: bad credentials/expired code/unknown token → + `401` / `{ active: false }`, never permissive. +- **Pagination gate** (`pagination.contract.test.ts`): every AuthN endpoint is + genuinely `x-pagination: exempt` (bounded/singleton); the spec's paginated + collections (AuthZ Phase 2) correctly encode the `{ items, page }` + + nullable-`nextCursor` envelope. The **runtime cursor-walk** targets those + AuthZ endpoints and is flagged `it.todo` — out of this AuthN suite's scope. + +## Subject under test — mock now, real impl later + +`harness.ts` resolves what the assertions run against: + +- **`SECURITY_BASE_URL` set** → runs the identical assertions over HTTP against a + real running implementation (ephemeral, FuzeInfra-pinned base services + + mocked external SaaS — never prod). This is the objective backend gate. +- **unset** (default) → an in-process **contract-mock** reference app + (`referenceApp.ts`) driven by `MockIdentityProvider`. This keeps the suite + runnable and proves the contract is satisfiable through the neutral interface + before the backend lands. + +`referenceApp.ts` + `mockIdentityProvider.ts` are **test fixtures**, not the +product. + +## Run + +```bash +# contract-mock (default) +npm test -w backend/security -- security-api + +# against a live implementation +SECURITY_BASE_URL=https:// npm test -w backend/security -- security-api +``` diff --git a/backend/security/tests/security-api/boundary.contract.test.ts b/backend/security/tests/security-api/boundary.contract.test.ts new file mode 100644 index 00000000..eb34eb88 --- /dev/null +++ b/backend/security/tests/security-api/boundary.contract.test.ts @@ -0,0 +1,67 @@ +/** + * Boundary / vendor-neutrality assertions. + * + * No AuthN response, redirect, or body may reference the internal identity host + * (`auth.fuzefront.com`) or name a vendor. Social `start` 302 must target a + * FuzeFront-owned (or Google) host only. This is the acute-leak regression gate + * at the API layer (the browser-level version lives in frontend-test-engineer's + * Playwright suite — out of this suite's scope). + */ +import { agent, RUNNING_AGAINST } from './harness' +import { + spec, + FORBIDDEN_INTERNAL_HOST, + FORBIDDEN_VENDOR_TOKENS, + ALLOWED_SOCIAL_HOSTS, +} from './spec' +import { SEED } from './mockIdentityProvider' + +function assertClean(label: string, text: string) { + const lower = text.toLowerCase() + expect(`${label}:${lower.includes(FORBIDDEN_INTERNAL_HOST)}`).toBe(`${label}:false`) + for (const vendor of FORBIDDEN_VENDOR_TOKENS) { + expect(`${label}/${vendor}:${lower.includes(vendor)}`).toBe(`${label}/${vendor}:false`) + } +} + +describe(`boundary + neutrality (against ${RUNNING_AGAINST})`, () => { + it('the frozen spec itself names no vendor in any consumer-facing path/schema key', () => { + // Descriptions may cite Google (a genuine social provider) but never our IdP vendor. + const pathsAndSchemas = JSON.stringify({ + paths: Object.keys(spec.paths), + schemas: Object.keys(spec.components.schemas), + }).toLowerCase() + assertClean('spec-names', pathsAndSchemas) + }) + + it('social start Location is a FuzeFront-owned/Google host, never the internal IdP', async () => { + const res = await agent().get('/api/v1/security/social/google/start').redirects(0) + const loc = res.headers['location'] || '' + assertClean('social-start-location', loc) + try { + const host = new URL(loc).host + expect(ALLOWED_SOCIAL_HOSTS.has(host)).toBe(true) + } catch { + /* relative Location = same-origin, inherently owned */ + } + }) + + it('successful login response references no vendor/internal host', async () => { + const res = await agent() + .post('/api/v1/security/session') + .send({ email: SEED.email, password: SEED.password }) + assertClean('login-body', JSON.stringify(res.body)) + }) + + it('error bodies reference no vendor/internal host', async () => { + const res = await agent() + .post('/api/v1/security/session') + .send({ email: SEED.email, password: 'wrong' }) + assertClean('error-body', JSON.stringify(res.body)) + }) + + it('/methods descriptor references no vendor/internal host', async () => { + const res = await agent().get('/api/v1/security/methods') + assertClean('methods-body', JSON.stringify(res.body)) + }) +}) diff --git a/backend/security/tests/security-api/harness.ts b/backend/security/tests/security-api/harness.ts new file mode 100644 index 00000000..4cd3b7af --- /dev/null +++ b/backend/security/tests/security-api/harness.ts @@ -0,0 +1,36 @@ +/** + * harness.ts — resolves the SUBJECT UNDER TEST for the AuthN contract suite. + * + * - If SECURITY_BASE_URL is set → run the identical spec assertions against the + * REAL running implementation (e.g. an ephemeral stack in CI). This is how the + * suite becomes objective verification of the backend once it lands. + * - Otherwise → fall back to the in-process contract-mock reference app driven + * by MockIdentityProvider. This keeps the suite runnable (and proves the + * contract is satisfiable through the neutral interface) before the impl lands. + * + * Either way, `agent()` returns a supertest instance the tests use uniformly. + */ +import supertest from 'supertest' +import { createSecurityApp } from './referenceApp' +import { IdentityProvider } from '../../src/providers/IdentityProvider' + +export const BASE_URL = process.env.SECURITY_BASE_URL + +export const RUNNING_AGAINST: 'live-implementation' | 'contract-mock' = BASE_URL + ? 'live-implementation' + : 'contract-mock' + +// A single persistent contract-mock app+provider is the in-process stand-in for +// "the running server": session/token state minted by one request must be +// visible to the next, exactly as it would be against a live backend. So the +// no-arg `agent()` reuses ONE app (and one MockIdentityProvider) for the whole +// run. Passing an explicit `provider` opts out (fresh app) — used by suites that +// want an isolated provider instance (mfa lifecycle, provider-swap). +let sharedApp: ReturnType | undefined + +export function agent(provider?: IdentityProvider) { + if (BASE_URL) return supertest(BASE_URL) + if (provider) return supertest(createSecurityApp(provider)) + if (!sharedApp) sharedApp = createSecurityApp() + return supertest(sharedApp) +} diff --git a/backend/security/tests/security-api/mfa.contract.test.ts b/backend/security/tests/security-api/mfa.contract.test.ts new file mode 100644 index 00000000..ea53fa92 --- /dev/null +++ b/backend/security/tests/security-api/mfa.contract.test.ts @@ -0,0 +1,166 @@ +/** + * Contract tests: full MFA lifecycle — factor enrollment/activation/removal, + * recovery codes, and the login step-up (challenge → verify) path. Schemas + + * status codes + fail-closed on bad codes, all against the frozen spec. + */ +import { agent, RUNNING_AGAINST } from './harness' +import { assertSchema } from './spec' +import { MockIdentityProvider, SEED } from './mockIdentityProvider' + +// The MFA lifecycle needs a stable provider instance across requests so an +// enrolled factor persists. Against a live impl (SECURITY_BASE_URL) state is +// held server-side, so a shared provider is only wired for the contract-mock. +function ctx() { + const provider = new MockIdentityProvider() + return { provider, api: () => agent(provider) } +} + +async function loggedInToken(api: () => ReturnType) { + const res = await api() + .post('/api/v1/security/session') + .send({ email: SEED.email, password: SEED.password }) + return res.body.token as string +} + +describe(`mfa lifecycle + step-up (against ${RUNNING_AGAINST})`, () => { + describe('factor management', () => { + it('GET /mfa/factors → { items: MfaFactor[] } (401 without token)', async () => { + const { api } = ctx() + const unauth = await api().get('/api/v1/security/mfa/factors') + expect(unauth.status).toBe(401) + assertSchema('ErrorBody', unauth.body) + + const token = await loggedInToken(api) + const res = await api() + .get('/api/v1/security/mfa/factors') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(200) + expect(Array.isArray(res.body.items)).toBe(true) + for (const f of res.body.items) assertSchema('MfaFactor', f) + }) + + it('enroll TOTP → 201 MfaEnrollResult with secret + provisioningUri', async () => { + const { api } = ctx() + const token = await loggedInToken(api) + const res = await api() + .post('/api/v1/security/mfa/factors') + .set('Authorization', `Bearer ${token}`) + .send({ type: 'totp' }) + expect(res.status).toBe(201) + assertSchema('MfaEnrollResult', res.body) + expect(res.body.type).toBe('totp') + expect(typeof res.body.secret).toBe('string') + expect(res.body.provisioningUri).toMatch(/^otpauth:\/\//) + }) + + it('enroll SMS without phone → 400 (fail-closed validation)', async () => { + const { api } = ctx() + const token = await loggedInToken(api) + const res = await api() + .post('/api/v1/security/mfa/factors') + .set('Authorization', `Bearer ${token}`) + .send({ type: 'sms' }) + expect(res.status).toBe(400) + assertSchema('ErrorBody', res.body) + }) + + it('enroll → activate happy path (200 active) and bad code → 400', async () => { + const { api } = ctx() + const token = await loggedInToken(api) + const enroll = await api() + .post('/api/v1/security/mfa/factors') + .set('Authorization', `Bearer ${token}`) + .send({ type: 'totp' }) + const factorId = enroll.body.factorId + + const bad = await api() + .post(`/api/v1/security/mfa/factors/${factorId}/activate`) + .set('Authorization', `Bearer ${token}`) + .send({ code: '999999' }) + expect(bad.status).toBe(400) + assertSchema('ErrorBody', bad.body) + + const ok = await api() + .post(`/api/v1/security/mfa/factors/${factorId}/activate`) + .set('Authorization', `Bearer ${token}`) + .send({ code: '000000' }) + expect(ok.status).toBe(200) + assertSchema('MfaFactor', ok.body) + expect(ok.body.status).toBe('active') + }) + + it('DELETE /mfa/factors/{id} → 204 (idempotent)', async () => { + const { api } = ctx() + const token = await loggedInToken(api) + const enroll = await api() + .post('/api/v1/security/mfa/factors') + .set('Authorization', `Bearer ${token}`) + .send({ type: 'totp' }) + const factorId = enroll.body.factorId + const del = await api() + .delete(`/api/v1/security/mfa/factors/${factorId}`) + .set('Authorization', `Bearer ${token}`) + expect(del.status).toBe(204) + }) + + it('POST /mfa/recovery-codes → 200 RecoveryCodes', async () => { + const { api } = ctx() + const token = await loggedInToken(api) + const res = await api() + .post('/api/v1/security/mfa/recovery-codes') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(200) + assertSchema('RecoveryCodes', res.body) + expect(res.body.codes.length).toBeGreaterThan(0) + }) + }) + + describe('login step-up (challenge → verify)', () => { + it('completes MFA and returns a LoginResponse', async () => { + const { api } = ctx() + // Trigger the mfa_required challenge. + const login = await api() + .post('/api/v1/security/session') + .send({ email: SEED.mfaEmail, password: SEED.mfaPassword }) + expect(login.body.status).toBe('mfa_required') + const { challengeId, factors } = login.body + const factorId = factors[0].factorId + + const challenge = await api() + .post('/api/v1/security/mfa/challenge') + .send({ challengeId, factorId }) + expect(challenge.status).toBe(202) + assertSchema('MfaChallengeAck', challenge.body) + + const verify = await api() + .post('/api/v1/security/mfa/verify') + .send({ challengeId, factorId, code: '000000' }) + expect(verify.status).toBe(200) + assertSchema('LoginResponse', verify.body) + expect(typeof verify.body.token).toBe('string') + }) + + it('bad OTP on verify → 401 (fail-closed, no session)', async () => { + const { api } = ctx() + const login = await api() + .post('/api/v1/security/session') + .send({ email: SEED.mfaEmail, password: SEED.mfaPassword }) + const { challengeId, factors } = login.body + const verify = await api() + .post('/api/v1/security/mfa/verify') + .send({ challengeId, factorId: factors[0].factorId, code: '111111' }) + expect(verify.status).toBe(401) + assertSchema('ErrorBody', verify.body) + expect(verify.body).not.toHaveProperty('token') + }) + + it('unknown challengeId on verify → 401 (fail-closed)', async () => { + const { api } = ctx() + const verify = await api() + .post('/api/v1/security/mfa/verify') + .send({ challengeId: 'nope', factorId: 'factor-totp-1', code: '000000' }) + expect(verify.status).toBe(401) + assertSchema('ErrorBody', verify.body) + }) + }) +}) diff --git a/backend/security/tests/security-api/mockIdentityProvider.ts b/backend/security/tests/security-api/mockIdentityProvider.ts new file mode 100644 index 00000000..bf879306 --- /dev/null +++ b/backend/security/tests/security-api/mockIdentityProvider.ts @@ -0,0 +1,393 @@ +/** + * MockIdentityProvider — a fully in-memory implementation of the internal + * `IdentityProvider` swap contract, used to PROVE provider-agnosticism. + * + * It names NO vendor and touches no network. If the whole login + MFA + + * verification surface works against this mock exactly as the spec describes, + * that is objective evidence the consumer-facing contract has no coupling to + * any concrete identity vendor. + * + * This is a TEST FIXTURE (a contract mock), NOT the product implementation. + */ +import { + IdentityProvider, + BrokeredSession, + BrokeredUser, + NormalizedIdentity, + PasswordLoginInput, + SignupInput, + SocialLoginStart, + SocialCallbackInput, + SocialCallbackResult, + M2MClientProvisionInput, + M2MClient, + M2MTokenInput, + M2MToken, + TokenIntrospection, + MfaFactor, + MfaEnrollInput, + MfaEnrollResult, + MfaChallengeAck, + VerificationStatus, +} from '../../src/providers/IdentityProvider' +import { randomUUID } from 'crypto' + +/** Provider errors carry a neutral, fail-closed error code (see spec ErrorBody). */ +export class ProviderError extends Error { + constructor( + public code: string, + message: string, + public httpStatus = 401 + ) { + super(message) + } +} + +interface StoredUser { + id: string + email: string + password: string + firstName?: string + lastName?: string + roles: string[] + mfaEnabled: boolean + factors: MfaFactor[] + factorSecrets: Map + emailVerified: boolean + phoneVerified: boolean + phone?: string +} + +// A well-known seeded user the contract tests log in as. +export const SEED = { + email: 'alice@example.com', + password: 'correct horse battery staple', + mfaEmail: 'mfauser@example.com', + mfaPassword: 'mfa-pass-1234', + socialCode: 'valid-provider-code', + socialState: 'valid-state', + m2mClientId: 'svc-client-1', + m2mClientSecret: 'svc-secret-1', +} + +const OTP = '000000' // the mock's accepted OTP for every challenge/verification + +export class MockIdentityProvider implements IdentityProvider { + private users = new Map() + private sessions = new Map() // token -> userId + private m2mTokens = new Map() + private brokerCodes = new Map() + private mfaChallenges = new Map() + private pendingSocialState = new Set([SEED.socialState]) + + constructor() { + this.seedUser({ + email: SEED.email, + password: SEED.password, + firstName: 'Alice', + lastName: 'Example', + roles: ['user'], + mfaEnabled: false, + }) + const mfaUser = this.seedUser({ + email: SEED.mfaEmail, + password: SEED.mfaPassword, + firstName: 'Mallory', + roles: ['user'], + mfaEnabled: true, + }) + // Give the MFA user one active TOTP factor. + mfaUser.factors.push({ + factorId: 'factor-totp-1', + type: 'totp', + status: 'active', + label: 'Authenticator app', + createdAt: Date.now(), + }) + } + + private seedUser(u: { + email: string + password: string + firstName?: string + lastName?: string + roles: string[] + mfaEnabled: boolean + }): StoredUser { + const stored: StoredUser = { + id: randomUUID(), + email: u.email, + password: u.password, + firstName: u.firstName, + lastName: u.lastName, + roles: u.roles, + mfaEnabled: u.mfaEnabled, + factors: [], + factorSecrets: new Map(), + emailVerified: false, + phoneVerified: false, + } + this.users.set(u.email.toLowerCase(), stored) + return stored + } + + private toUser(s: StoredUser): BrokeredUser { + return { + id: s.id, + email: s.email, + firstName: s.firstName, + lastName: s.lastName, + roles: s.roles, + } + } + + private mint(s: StoredUser): BrokeredSession { + const token = `sess_${randomUUID()}` + this.sessions.set(token, s.id) + return { token, sessionId: `sid_${randomUUID()}`, user: this.toUser(s) } + } + + private userByToken(token: string): StoredUser { + const userId = this.sessions.get(token) + if (!userId) throw new ProviderError('INVALID_SIGNATURE', 'unknown session', 401) + for (const u of this.users.values()) if (u.id === userId) return u + throw new ProviderError('INVALID_SIGNATURE', 'session user gone', 401) + } + + /** True when the account requires MFA step-up (surfaced by the route as SessionResult). */ + requiresMfa(email: string): boolean { + const u = this.users.get(email.toLowerCase()) + return !!u && u.mfaEnabled + } + + factorsFor(email: string): MfaFactor[] { + const u = this.users.get(email.toLowerCase()) + return u ? u.factors : [] + } + + async passwordLogin(input: PasswordLoginInput): Promise { + const u = this.users.get(input.email.toLowerCase()) + if (!u || u.password !== input.password) { + throw new ProviderError('INVALID_CREDENTIALS', 'bad credentials', 401) + } + return this.mint(u) + } + + async startSocialLogin(provider: string, redirectTo?: string): Promise { + if (provider !== 'google') { + throw new ProviderError('MALFORMED', `unsupported provider ${provider}`, 400) + } + const state = `state_${randomUUID()}` + this.pendingSocialState.add(state) + // FuzeFront-OWNED same-host authorize path. Never an internal identity host. + const url = new URL('https://app.fuzefront.com/api/auth/idp/authorize') + url.searchParams.set('state', state) + if (redirectTo) url.searchParams.set('redirectTo', redirectTo) + return { redirectUrl: url.toString(), state } + } + + async brokerCallback(input: SocialCallbackInput): Promise { + if ( + !this.pendingSocialState.has(input.state) || + (input.code !== SEED.socialCode && !input.code.startsWith('state_') && input.code.length < 3) + ) { + throw new ProviderError('INVALID_CODE', 'bad state/code', 401) + } + // Provision/link the social user. + let u = this.users.get('social@example.com') + if (!u) { + u = this.seedUser({ + email: 'social@example.com', + password: randomUUID(), + firstName: 'Soc', + roles: ['user'], + mfaEnabled: false, + }) + } + const code = `broker_${randomUUID()}` + this.brokerCodes.set(code, { userId: u.id, redirectTo: '/dashboard' }) + return { code, redirectTo: '/dashboard' } + } + + async exchangeCode(code: string): Promise { + const entry = this.brokerCodes.get(code) + if (!entry) throw new ProviderError('INVALID_CODE', 'unknown/expired code', 401) + this.brokerCodes.delete(code) // single-use + for (const u of this.users.values()) if (u.id === entry.userId) return this.mint(u) + throw new ProviderError('INVALID_CODE', 'user gone', 401) + } + + async signup(input: SignupInput): Promise { + if (this.users.has(input.email.toLowerCase())) { + throw new ProviderError('CONFLICT', 'email exists', 409) + } + const u = this.seedUser({ + email: input.email, + password: input.password, + firstName: input.firstName, + lastName: input.lastName, + roles: ['user'], + mfaEnabled: false, + }) + return this.mint(u) + } + + async getUserInfo( + token: string + ): Promise<{ identity: NormalizedIdentity; user: BrokeredUser }> { + const u = this.userByToken(token) + const now = Math.floor(Date.now() / 1000) + return { + identity: { + userId: u.id, + tenantId: null, + roles: u.roles, + email: u.email, + authMode: 'legacy-hs256', + issuedAt: now, + expiresAt: now + 3600, + issuer: 'https://app.fuzefront.com', + }, + user: this.toUser(u), + } + } + + async logout(token: string): Promise { + this.sessions.delete(token) // idempotent + } + + async provisionM2MClient(input: M2MClientProvisionInput): Promise { + return { clientId: `svc_${randomUUID()}`, clientSecret: randomUUID(), scope: input.scope } + } + + async issueM2MToken(input: M2MTokenInput): Promise { + if (input.clientId !== SEED.m2mClientId || input.clientSecret !== SEED.m2mClientSecret) { + throw new ProviderError('INVALID_CREDENTIALS', 'bad client credentials', 401) + } + const accessToken = `m2m_${randomUUID()}` + const exp = Math.floor(Date.now() / 1000) + 3600 + this.m2mTokens.set(accessToken, { subject: input.clientId, scope: input.scope, exp }) + return { accessToken, tokenType: 'Bearer', expiresIn: 3600, scope: input.scope } + } + + async introspectToken(token: string): Promise { + const entry = this.m2mTokens.get(token) + if (!entry || entry.exp < Math.floor(Date.now() / 1000)) { + return { active: false } // fail-closed + } + return { + active: true, + subject: entry.subject, + tenantId: null, + scope: entry.scope, + expiresAt: entry.exp, + } + } + + async listFactors(token: string): Promise { + return this.userByToken(token).factors + } + + async enrollFactor(token: string, input: MfaEnrollInput): Promise { + const u = this.userByToken(token) + if (input.type === 'sms' && !input.phone) + throw new ProviderError('MALFORMED', 'phone required for sms', 400) + if (input.type === 'email' && !input.email) + throw new ProviderError('MALFORMED', 'email required for email', 400) + const factorId = `factor_${randomUUID()}` + const factor: MfaFactor = { + factorId, + type: input.type, + status: 'pending', + label: input.phone || input.email || input.type, + createdAt: Date.now(), + } + u.factors.push(factor) + const result: MfaEnrollResult = { factorId, type: input.type, status: 'pending' } + if (input.type === 'totp') { + const secret = 'JBSWY3DPEHPK3PXP' + u.factorSecrets.set(factorId, secret) + result.secret = secret + result.provisioningUri = `otpauth://totp/FuzeFront:${u.email}?secret=${secret}&issuer=FuzeFront` + } else { + result.codeSent = true + } + return result + } + + async activateFactor(token: string, factorId: string, code: string): Promise { + const u = this.userByToken(token) + const factor = u.factors.find((f) => f.factorId === factorId) + if (!factor) throw new ProviderError('NOT_FOUND', 'no such factor', 404) + if (code !== OTP) throw new ProviderError('INVALID_CODE', 'bad code', 400) + factor.status = 'active' + return factor + } + + async removeFactor(token: string, factorId: string): Promise { + const u = this.userByToken(token) + u.factors = u.factors.filter((f) => f.factorId !== factorId) // idempotent + } + + async regenerateRecoveryCodes(token: string): Promise { + this.userByToken(token) + return Array.from({ length: 8 }, () => randomUUID().slice(0, 10)) + } + + async challengeMfa(challengeId: string, factorId: string): Promise { + const ch = this.mfaChallenges.get(challengeId) + if (!ch) throw new ProviderError('INVALID_CODE', 'unknown challenge', 401) + return { challengeId, factorId, delivered: true } + } + + async verifyMfa( + challengeId: string, + factorId: string, + code: string + ): Promise { + const ch = this.mfaChallenges.get(challengeId) + if (!ch) throw new ProviderError('INVALID_CODE', 'unknown challenge', 401) + if (code !== OTP) throw new ProviderError('INVALID_CODE', 'bad code', 401) + this.mfaChallenges.delete(challengeId) + for (const u of this.users.values()) if (u.id === ch.userId) return this.mint(u) + throw new ProviderError('INVALID_CODE', 'user gone', 401) + } + + /** Called by the route when password login hits an MFA-enabled account. */ + openMfaChallenge(email: string): { challengeId: string; factors: MfaFactor[] } { + const u = this.users.get(email.toLowerCase())! + const challengeId = `chal_${randomUUID()}` + this.mfaChallenges.set(challengeId, { userId: u.id, factorId: u.factors[0]?.factorId }) + return { challengeId, factors: u.factors } + } + + async startEmailVerification(token: string | null, email?: string): Promise { + if (!token && !email) throw new ProviderError('MALFORMED', 'need token or email', 400) + // no-op dispatch + } + + async confirmEmailVerification(input: { + token?: string + code?: string + }): Promise { + if (input.token !== 'valid-email-token' && input.code !== OTP) { + throw new ProviderError('INVALID_CODE', 'bad token/code', 400) + } + return { emailVerified: true, phoneVerified: false } + } + + async startPhoneVerification(token: string, phone: string): Promise { + this.userByToken(token) + if (!phone) throw new ProviderError('MALFORMED', 'phone required', 400) + } + + async confirmPhoneVerification(phone: string, code: string): Promise { + if (code !== OTP) throw new ProviderError('INVALID_CODE', 'bad code', 400) + return { emailVerified: false, phoneVerified: true, phone } + } + + async getVerificationStatus(token: string): Promise { + const u = this.userByToken(token) + return { emailVerified: u.emailVerified, phoneVerified: u.phoneVerified, phone: u.phone } + } +} diff --git a/backend/security/tests/security-api/pagination.contract.test.ts b/backend/security/tests/security-api/pagination.contract.test.ts new file mode 100644 index 00000000..efe6ad7f --- /dev/null +++ b/backend/security/tests/security-api/pagination.contract.test.ts @@ -0,0 +1,94 @@ +/** + * Pagination gate (family standard, baseline §4.1). + * + * This suite owns the AuthN slice — where EVERY endpoint is `x-pagination: + * exempt` (singleton actions / bounded per-user sets). The gate here therefore: + * 1. asserts every AuthN endpoint is genuinely exempt WITH a reason, and that + * the responses are bounded/singleton (no unbounded array + no page cursor); + * 2. asserts, at the CONTRACT level, that the spec's genuinely-paginated + * collection endpoints declare limit+cursor and use the `{ items, page }` + * envelope with a nullable `nextCursor` + `hasMore` — i.e. the standard is + * correctly encoded in the frozen spec. + * + * The RUNTIME cursor-walk verification (limit clamping + walking the whole set + * with no gaps/dupes) targets the paginated endpoints, which are all in the + * AuthZ/tenants tags — Phase 2, OUT OF SCOPE for this AuthN suite. That is + * flagged, not silently skipped: see the `it.todo` markers below. + */ +import { spec, listEndpoints, assertSchema } from './spec' + +const AUTHN_TAGS = new Set([ + 'session', + 'social', + 'signup', + 'capabilities', + 'mfa', + 'verify', + 'tokens', +]) + +function opTags(path: string, method: string): string[] { + return spec.paths[path]?.[method]?.tags || [] +} + +describe('pagination gate — frozen contract', () => { + const endpoints = listEndpoints() + + it('every AuthN endpoint is x-pagination: exempt WITH a reason', () => { + const authn = endpoints.filter((e) => opTags(e.path, e.method).some((t) => AUTHN_TAGS.has(t))) + expect(authn.length).toBeGreaterThan(0) + for (const e of authn) { + expect({ ep: `${e.method} ${e.path}`, exempt: e.exempt }).toEqual({ + ep: `${e.method} ${e.path}`, + exempt: true, + }) + expect(typeof e.exemptReason).toBe('string') + expect((e.exemptReason || '').length).toBeGreaterThan(0) + } + }) + + it('no AuthN endpoint declares limit/cursor params (they are not paginated)', () => { + const authn = endpoints.filter((e) => opTags(e.path, e.method).some((t) => AUTHN_TAGS.has(t))) + for (const e of authn) expect(e.paginated).toBe(false) + }) + + it('the Limit param declares a server-side maximum (clamp is spec-mandated)', () => { + const limit = spec.components.parameters.Limit + expect(limit.schema.maximum).toBeGreaterThan(0) + expect(typeof limit.schema.default).toBe('number') + expect(limit.schema.default).toBeLessThanOrEqual(limit.schema.maximum) + }) + + it('PageInfo envelope matches the family standard (nextCursor nullable + hasMore required)', () => { + const pi = spec.components.schemas.PageInfo + expect(pi.required).toEqual(expect.arrayContaining(['nextCursor', 'hasMore'])) + // nextCursor must be nullable (string | null) for cursor termination. + expect(pi.properties.nextCursor.type).toEqual(expect.arrayContaining(['string', 'null'])) + // A valid terminal page validates. + assertSchema('PageInfo', { nextCursor: null, hasMore: false }) + // A mid-walk page validates. + assertSchema('PageInfo', { nextCursor: 'opaque', hasMore: true, total: 42 }) + }) + + it('every paginated collection uses the { items, page } envelope', () => { + const paginated = endpoints.filter((e) => e.paginated) + // In the frozen spec these are AuthZ/tenants (Phase 2), not AuthN. + expect(paginated.map((e) => `${e.method} ${e.path}`).sort()).toEqual([ + 'get /v1/security/authz/grants', + 'get /v1/security/tenants', + 'get /v1/security/tenants/{tenantId}/members', + ]) + const pageSchemas = ['GrantPage', 'TenantPage', 'MemberPage'] + for (const name of pageSchemas) { + const s = spec.components.schemas[name] + expect(s.required).toEqual(expect.arrayContaining(['items', 'page'])) + expect(s.properties.items.type).toBe('array') + expect(s.properties.page.$ref).toContain('PageInfo') + } + }) + + // RUNTIME cursor-walk verification belongs to the AuthZ slice (Phase 2). + it.todo( + 'RUNTIME: limit clamping + cursor walks the whole set (no gaps/dupes, terminates) — AuthZ Phase 2, out of this AuthN suite' + ) +}) diff --git a/backend/security/tests/security-api/provider-swap.contract.test.ts b/backend/security/tests/security-api/provider-swap.contract.test.ts new file mode 100644 index 00000000..d35ec26b --- /dev/null +++ b/backend/security/tests/security-api/provider-swap.contract.test.ts @@ -0,0 +1,214 @@ +/** + * PROVIDER-SWAP PROOF. + * + * The reference app (referenceApp.ts) depends ONLY on the `IdentityProvider` + * interface. Here we drive the full AuthN surface through a SECOND, completely + * independent implementation (`AltIdentityProvider`) that shares NO code with + * MockIdentityProvider and uses a different token format + storage. If the same + * spec-conformant login + MFA-less + M2M + verification path passes unchanged, + * that is objective evidence the consumer-facing contract has zero coupling to + * any concrete identity vendor — the whole point of the provider-agnostic layer. + */ +import supertest from 'supertest' +import { createSecurityApp } from './referenceApp' +import { assertSchema } from './spec' +import { + IdentityProvider, + BrokeredSession, + BrokeredUser, + NormalizedIdentity, + PasswordLoginInput, + SignupInput, + SocialLoginStart, + SocialCallbackInput, + SocialCallbackResult, + M2MClientProvisionInput, + M2MClient, + M2MTokenInput, + M2MToken, + TokenIntrospection, + MfaFactor, + MfaEnrollInput, + MfaEnrollResult, + MfaChallengeAck, + VerificationStatus, +} from '../../src/providers/IdentityProvider' +import { ProviderError } from './mockIdentityProvider' +import { randomUUID } from 'crypto' + +/** A deliberately different second implementation of the SAME swap contract. */ +class AltIdentityProvider implements IdentityProvider { + // ids are uuid-format (the contract's User.id format) but everything else — + // token format, storage, roles — differs from MockIdentityProvider. + private users: Record = { + 'bob@alt.test': { id: randomUUID(), pw: 'alt-pass-99' }, + } + private live = new Set() + private m2m = new Map() + private codes = new Map() + private n = 0 + private tok() { + return `ALT-TOKEN-${this.n++}` // different format from the mock's `sess_...` + } + private mint(email: string): BrokeredSession { + const t = this.tok() + this.live.add(t) + return { + token: t, + sessionId: `ALTSID-${this.n++}`, + user: { id: this.users[email].id, email, roles: ['member'] }, + } + } + async passwordLogin(i: PasswordLoginInput): Promise { + const u = this.users[i.email] + if (!u || u.pw !== i.password) throw new ProviderError('INVALID_CREDENTIALS', 'nope', 401) + return this.mint(i.email) + } + async startSocialLogin(provider: string): Promise { + if (provider !== 'google') throw new ProviderError('MALFORMED', 'bad provider', 400) + return { redirectUrl: 'https://app.fuzefront.com/api/auth/idp/authorize?x=1', state: 's' } + } + async brokerCallback(i: SocialCallbackInput): Promise { + const code = `ALTCODE-${this.n++}` + this.codes.set(code, 'bob@alt.test') + return { code, redirectTo: '/home' } + } + async exchangeCode(code: string): Promise { + const email = this.codes.get(code) + if (!email) throw new ProviderError('INVALID_CODE', 'unknown', 401) + this.codes.delete(code) + return this.mint(email) + } + async signup(i: SignupInput): Promise { + if (this.users[i.email]) throw new ProviderError('CONFLICT', 'exists', 409) + this.users[i.email] = { id: `ALT-${this.n++}`, pw: i.password } + return this.mint(i.email) + } + async getUserInfo(t: string): Promise<{ identity: NormalizedIdentity; user: BrokeredUser }> { + if (!this.live.has(t)) throw new ProviderError('INVALID_SIGNATURE', 'bad token', 401) + const id = this.users['bob@alt.test'].id + return { + identity: { + userId: id, + tenantId: null, + roles: ['member'], + authMode: 'federated-jwks', + }, + user: { id, email: 'bob@alt.test', roles: ['member'] }, + } + } + async logout(t: string): Promise { + this.live.delete(t) + } + async provisionM2MClient(i: M2MClientProvisionInput): Promise { + return { clientId: 'alt-client', clientSecret: 'alt-secret' } + } + async issueM2MToken(i: M2MTokenInput): Promise { + if (i.clientId !== 'alt-client' || i.clientSecret !== 'alt-secret') + throw new ProviderError('INVALID_CREDENTIALS', 'bad', 401) + const t = `ALT-M2M-${this.n++}` + this.m2m.set(t, Math.floor(Date.now() / 1000) + 3600) + return { accessToken: t, tokenType: 'Bearer', expiresIn: 3600 } + } + async introspectToken(t: string): Promise { + const exp = this.m2m.get(t) + if (!exp) return { active: false } + return { active: true, subject: 'alt-client', expiresAt: exp } + } + async listFactors(): Promise { + return [] + } + async enrollFactor(_t: string, _i: MfaEnrollInput): Promise { + throw new ProviderError('UNKNOWN', 'n/a', 500) + } + async activateFactor(): Promise { + throw new ProviderError('UNKNOWN', 'n/a', 500) + } + async removeFactor(): Promise {} + async regenerateRecoveryCodes(): Promise { + return ['a', 'b'] + } + async challengeMfa(): Promise { + throw new ProviderError('INVALID_CODE', 'n/a', 401) + } + async verifyMfa(): Promise { + throw new ProviderError('INVALID_CODE', 'n/a', 401) + } + async startEmailVerification(): Promise {} + async confirmEmailVerification(): Promise { + return { emailVerified: true, phoneVerified: false } + } + async startPhoneVerification(): Promise {} + async confirmPhoneVerification(): Promise { + return { emailVerified: false, phoneVerified: true } + } + async getVerificationStatus(t: string): Promise { + if (!this.live.has(t)) throw new ProviderError('INVALID_SIGNATURE', 'bad', 401) + return { emailVerified: false, phoneVerified: false } + } +} + +describe('provider-swap proof — full path through an independent IdentityProvider', () => { + // If we are pointed at a live implementation, the swap is proven by the impl + // itself; this in-process proof only runs for the contract-mock configuration. + const runIt = process.env.SECURITY_BASE_URL ? it.skip : it + // ONE app + ONE alternate provider for the whole proof, so a token minted by + // login is visible to the follow-up "me"/introspect calls (the alt provider + // holds session/token state in memory, mirroring a live server). + const app = createSecurityApp(new AltIdentityProvider()) + const api = () => supertest(app) + + runIt('login → me → logout works identically under the alternate provider', async () => { + const login = await api() + .post('/api/v1/security/session') + .send({ email: 'bob@alt.test', password: 'alt-pass-99' }) + expect(login.status).toBe(200) + assertSchema('SessionResult', login.body) + expect(login.body.status).toBe('authenticated') + expect(login.body.token).toMatch(/^ALT-TOKEN-/) // proves the alt impl is in play + + const me = await api() + .get('/api/v1/security/session') + .set('Authorization', `Bearer ${login.body.token}`) + expect(me.status).toBe(200) + assertSchema('SessionInfo', me.body) + expect(me.body.identity.authMode).toBe('federated-jwks') + + const out = await api() + .delete('/api/v1/security/session') + .set('Authorization', `Bearer ${login.body.token}`) + expect(out.status).toBe(204) + }) + + runIt('bad credentials still fail-closed under the alternate provider', async () => { + const res = await api() + .post('/api/v1/security/session') + .send({ email: 'bob@alt.test', password: 'WRONG' }) + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + }) + + runIt('social start 302 stays FuzeFront-owned under the alternate provider', async () => { + const res = await api().get('/api/v1/security/social/google/start').redirects(0) + expect(res.status).toBe(302) + expect((res.headers['location'] || '').toLowerCase()).not.toContain('auth.fuzefront.com') + }) + + runIt('M2M issue + fail-closed introspection under the alternate provider', async () => { + const issued = await api() + .post('/api/v1/security/tokens') + .send({ clientId: 'alt-client', clientSecret: 'alt-secret' }) + expect(issued.status).toBe(200) + assertSchema('TokenIssueResponse', issued.body) + + const active = await api() + .post('/api/v1/security/tokens/introspect') + .send({ token: issued.body.accessToken }) + expect(active.body.active).toBe(true) + + const inactive = await api() + .post('/api/v1/security/tokens/introspect') + .send({ token: 'garbage' }) + expect(inactive.body.active).toBe(false) + }) +}) diff --git a/backend/security/tests/security-api/referenceApp.ts b/backend/security/tests/security-api/referenceApp.ts new file mode 100644 index 00000000..513aff62 --- /dev/null +++ b/backend/security/tests/security-api/referenceApp.ts @@ -0,0 +1,330 @@ +/** + * referenceApp.ts — a THIN Express app that maps the frozen Security API spec + * onto ANY `IdentityProvider`. It exists purely as a test fixture ("contract + * mock until the real impl lands"), so the independent suite has something to + * run its spec assertions against, and so the provider-swap proof can drive the + * full AuthN surface through the neutral interface. + * + * It deliberately implements ONLY the AuthN slice this suite owns + * (session/social/signup/methods/mfa/verify/tokens). AuthZ + tenant routes are + * out of scope for this suite. + * + * NOTE: This is NOT the product. When the real backend lands, set + * SECURITY_BASE_URL to run the identical assertions against it (see harness.ts). + */ +import express, { Request, Response, NextFunction } from 'express' +import { IdentityProvider } from '../../src/providers/IdentityProvider' +import { MockIdentityProvider, ProviderError } from './mockIdentityProvider' + +const CODE_TO_STATUS: Record = { + NO_TOKEN: 401, + MALFORMED: 400, + INVALID_SIGNATURE: 401, + EXPIRED: 401, + NOT_ACTIVE: 401, + INVALID_CREDENTIALS: 401, + INVALID_CODE: 401, + CONFLICT: 409, + FORBIDDEN: 403, + NOT_FOUND: 404, + PROVIDER_UNAVAILABLE: 503, + UNKNOWN: 500, +} + +function sendError(res: Response, code: string, error: string, statusOverride?: number) { + const status = statusOverride ?? CODE_TO_STATUS[code] ?? 500 + res.status(status).json({ error, code }) +} + +function bearer(req: Request): string | null { + const h = req.header('authorization') || '' + const m = /^Bearer (.+)$/.exec(h) + return m ? m[1] : null +} + +function handle(res: Response, e: unknown) { + if (e instanceof ProviderError) return sendError(res, e.code, e.message, e.httpStatus) + return sendError(res, 'UNKNOWN', 'unexpected error', 500) +} + +export function createSecurityApp(provider: IdentityProvider = new MockIdentityProvider()) { + const app = express() + app.use(express.json()) + const mock = provider as MockIdentityProvider // for MFA-branch helpers on the fixture + + const requireAuth = (req: Request, res: Response, next: NextFunction) => { + const token = bearer(req) + if (!token) return sendError(res, 'NO_TOKEN', 'missing bearer token') + ;(req as any).token = token + next() + } + + // ── session ────────────────────────────────────────────────────────────── + app.post('/api/v1/security/session', async (req, res) => { + const { email, password } = req.body || {} + if (typeof email !== 'string' || typeof password !== 'string') { + return sendError(res, 'MALFORMED', 'email and password required', 400) + } + try { + if (typeof mock.requiresMfa === 'function' && mock.requiresMfa(email)) { + // Validate credentials still (fail-closed) before opening a challenge. + try { + await provider.passwordLogin({ email, password }) + } catch (e) { + return handle(res, e) + } + const { challengeId, factors } = mock.openMfaChallenge(email) + return res.status(200).json({ + status: 'mfa_required', + challengeId, + factors: factors.map((f) => ({ factorId: f.factorId, type: f.type })), + }) + } + const s = await provider.passwordLogin({ email, password }) + return res.status(200).json({ status: 'authenticated', ...s }) + } catch (e) { + handle(res, e) + } + }) + + app.get('/api/v1/security/session', requireAuth, async (req, res) => { + try { + const info = await provider.getUserInfo((req as any).token) + res.status(200).json(info) + } catch (e) { + handle(res, e) + } + }) + + app.delete('/api/v1/security/session', requireAuth, async (req, res) => { + try { + await provider.logout((req as any).token) + res.status(204).end() + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/session/exchange', async (req, res) => { + const { code } = req.body || {} + if (typeof code !== 'string') return sendError(res, 'MALFORMED', 'code required', 400) + try { + const s = await provider.exchangeCode(code) + res.status(200).json({ status: 'authenticated', ...s }) + } catch (e) { + handle(res, e) + } + }) + + // ── social ─────────────────────────────────────────────────────────────── + app.get('/api/v1/security/social/:provider/start', async (req, res) => { + const redirectTo = typeof req.query.redirectTo === 'string' ? req.query.redirectTo : undefined + if (redirectTo && /^https?:\/\//i.test(redirectTo)) { + return sendError(res, 'MALFORMED', 'redirectTo must be same-origin', 400) + } + try { + const start = await provider.startSocialLogin(req.params.provider, redirectTo) + res.redirect(302, start.redirectUrl) + } catch (e) { + handle(res, e) + } + }) + + app.get('/api/v1/security/social/callback', async (req, res) => { + const { code, state } = req.query + if (typeof code !== 'string' || typeof state !== 'string') { + return sendError(res, 'INVALID_CODE', 'code and state required', 401) + } + try { + const result = await provider.brokerCallback({ code, state }) + const loc = `${result.redirectTo}?code=${encodeURIComponent(result.code)}` + res.redirect(302, loc) + } catch (e) { + handle(res, e) + } + }) + + // ── signup ─────────────────────────────────────────────────────────────── + app.post('/api/v1/security/signup', async (req, res) => { + const { email, password } = req.body || {} + if (typeof email !== 'string' || typeof password !== 'string') { + return sendError(res, 'MALFORMED', 'email and password required', 400) + } + try { + const s = await provider.signup(req.body) + res.status(201).json({ token: s.token, sessionId: s.sessionId, user: s.user }) + } catch (e) { + handle(res, e) + } + }) + + // ── methods ────────────────────────────────────────────────────────────── + app.get('/api/v1/security/methods', async (_req, res) => { + res.status(200).json({ + password: true, + social: ['google'], + mfa: { enabled: true, types: ['totp', 'sms', 'email'] }, + verification: { email: true, sms: true }, + }) + }) + + // ── mfa: factors ─────────────────────────────────────────────────────────── + app.get('/api/v1/security/mfa/factors', requireAuth, async (req, res) => { + try { + const items = await provider.listFactors((req as any).token) + res.status(200).json({ items }) + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/mfa/factors', requireAuth, async (req, res) => { + const { type } = req.body || {} + if (!['totp', 'sms', 'email', 'webauthn'].includes(type)) { + return sendError(res, 'MALFORMED', 'valid type required', 400) + } + try { + const result = await provider.enrollFactor((req as any).token, req.body) + res.status(201).json(result) + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/mfa/factors/:factorId/activate', requireAuth, async (req, res) => { + const { code } = req.body || {} + if (typeof code !== 'string') return sendError(res, 'MALFORMED', 'code required', 400) + try { + const factor = await provider.activateFactor((req as any).token, req.params.factorId, code) + res.status(200).json(factor) + } catch (e) { + handle(res, e) + } + }) + + app.delete('/api/v1/security/mfa/factors/:factorId', requireAuth, async (req, res) => { + try { + await provider.removeFactor((req as any).token, req.params.factorId) + res.status(204).end() + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/mfa/recovery-codes', requireAuth, async (req, res) => { + try { + const codes = await provider.regenerateRecoveryCodes((req as any).token) + res.status(200).json({ codes }) + } catch (e) { + handle(res, e) + } + }) + + // ── mfa: step-up ─────────────────────────────────────────────────────────── + app.post('/api/v1/security/mfa/challenge', async (req, res) => { + const { challengeId, factorId } = req.body || {} + if (typeof challengeId !== 'string' || typeof factorId !== 'string') { + return sendError(res, 'MALFORMED', 'challengeId and factorId required', 400) + } + try { + const ack = await provider.challengeMfa(challengeId, factorId) + res.status(202).json(ack) + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/mfa/verify', async (req, res) => { + const { challengeId, factorId, code } = req.body || {} + if (typeof challengeId !== 'string' || typeof factorId !== 'string' || typeof code !== 'string') { + return sendError(res, 'MALFORMED', 'challengeId, factorId, code required', 400) + } + try { + const s = await provider.verifyMfa(challengeId, factorId, code) + res.status(200).json({ token: s.token, sessionId: s.sessionId, user: s.user }) + } catch (e) { + handle(res, e) + } + }) + + // ── m2m tokens ───────────────────────────────────────────────────────────── + app.post('/api/v1/security/tokens', async (req, res) => { + const { clientId, clientSecret } = req.body || {} + if (typeof clientId !== 'string' || typeof clientSecret !== 'string') { + return sendError(res, 'MALFORMED', 'clientId and clientSecret required', 400) + } + try { + const t = await provider.issueM2MToken(req.body) + res.status(200).json(t) + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/tokens/introspect', async (req, res) => { + const { token } = req.body || {} + if (typeof token !== 'string') return sendError(res, 'MALFORMED', 'token required', 400) + try { + const result = await provider.introspectToken(token) + res.status(200).json(result) // fail-closed → { active: false }, still 200 + } catch (e) { + handle(res, e) + } + }) + + // ── contact verification ──────────────────────────────────────────────────── + app.post('/api/v1/security/verify/email/start', async (req, res) => { + const token = bearer(req) + const email = req.body?.email + try { + await provider.startEmailVerification(token, email) + res.status(202).end() + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/verify/email/confirm', async (req, res) => { + try { + const status = await provider.confirmEmailVerification(req.body || {}) + res.status(200).json(status) + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/verify/phone/start', requireAuth, async (req, res) => { + const { phone } = req.body || {} + if (typeof phone !== 'string') return sendError(res, 'MALFORMED', 'phone required', 400) + try { + await provider.startPhoneVerification((req as any).token, phone) + res.status(202).end() + } catch (e) { + handle(res, e) + } + }) + + app.post('/api/v1/security/verify/phone/confirm', async (req, res) => { + const { phone, code } = req.body || {} + if (typeof phone !== 'string' || typeof code !== 'string') { + return sendError(res, 'MALFORMED', 'phone and code required', 400) + } + try { + const status = await provider.confirmPhoneVerification(phone, code) + res.status(200).json(status) + } catch (e) { + handle(res, e) + } + }) + + app.get('/api/v1/security/verify/status', requireAuth, async (req, res) => { + try { + const status = await provider.getVerificationStatus((req as any).token) + res.status(200).json(status) + } catch (e) { + handle(res, e) + } + }) + + return app +} diff --git a/backend/security/tests/security-api/session.contract.test.ts b/backend/security/tests/security-api/session.contract.test.ts new file mode 100644 index 00000000..72bb5715 --- /dev/null +++ b/backend/security/tests/security-api/session.contract.test.ts @@ -0,0 +1,118 @@ +/** + * Contract tests: AuthN session lifecycle + code exchange + the SessionResult + * MFA-step-up discriminated union. Asserts status codes and response schemas + * against the FROZEN spec (packages/security/openapi.yaml). + */ +import { agent, RUNNING_AGAINST } from './harness' +import { assertSchema } from './spec' +import { SEED } from './mockIdentityProvider' + +describe(`session (against ${RUNNING_AGAINST})`, () => { + describe('POST /session — password login', () => { + it('200 → SessionResult(authenticated) for valid credentials', async () => { + const res = await agent() + .post('/api/v1/security/session') + .send({ email: SEED.email, password: SEED.password }) + expect(res.status).toBe(200) + // SessionResult union — the authenticated variant. + assertSchema('SessionResult', res.body) + expect(res.body.status).toBe('authenticated') + expect(typeof res.body.token).toBe('string') + expect(res.body.user).toBeDefined() + assertSchema('AuthenticatedSession', res.body) + }) + + it('200 → SessionResult(mfa_required) when the account has MFA enabled', async () => { + const res = await agent() + .post('/api/v1/security/session') + .send({ email: SEED.mfaEmail, password: SEED.mfaPassword }) + expect(res.status).toBe(200) + assertSchema('SessionResult', res.body) + expect(res.body.status).toBe('mfa_required') + assertSchema('MfaRequiredChallenge', res.body) + expect(typeof res.body.challengeId).toBe('string') + expect(Array.isArray(res.body.factors)).toBe(true) + for (const f of res.body.factors) assertSchema('MfaFactorRef', f) + }) + + it('401 → ErrorBody on bad credentials (fail-closed, never a session)', async () => { + const res = await agent() + .post('/api/v1/security/session') + .send({ email: SEED.email, password: 'wrong-password' }) + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + expect(res.body).not.toHaveProperty('token') + }) + + it('400 → ErrorBody on malformed request (missing password)', async () => { + const res = await agent().post('/api/v1/security/session').send({ email: SEED.email }) + expect(res.status).toBe(400) + assertSchema('ErrorBody', res.body) + }) + }) + + describe('GET /session — current identity ("me")', () => { + it('200 → SessionInfo for a valid bearer token', async () => { + const login = await agent() + .post('/api/v1/security/session') + .send({ email: SEED.email, password: SEED.password }) + const token = login.body.token + const res = await agent() + .get('/api/v1/security/session') + .set('Authorization', `Bearer ${token}`) + expect(res.status).toBe(200) + assertSchema('SessionInfo', res.body) + assertSchema('Identity', res.body.identity) + }) + + it('401 → ErrorBody with no token (fail-closed)', async () => { + const res = await agent().get('/api/v1/security/session') + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + }) + + it('401 → ErrorBody with an unknown/garbage token', async () => { + const res = await agent() + .get('/api/v1/security/session') + .set('Authorization', 'Bearer not-a-real-token') + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + }) + }) + + describe('DELETE /session — logout', () => { + it('204 revokes the current session and is idempotent', async () => { + const login = await agent() + .post('/api/v1/security/session') + .send({ email: SEED.email, password: SEED.password }) + const token = login.body.token + const first = await agent() + .delete('/api/v1/security/session') + .set('Authorization', `Bearer ${token}`) + expect(first.status).toBe(204) + }) + + it('401 without a token', async () => { + const res = await agent().delete('/api/v1/security/session') + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + }) + }) + + describe('POST /session/exchange — opaque broker code → session', () => { + it('401 → ErrorBody on an unknown/expired code (fail-closed)', async () => { + const res = await agent() + .post('/api/v1/security/session/exchange') + .send({ code: 'totally-unknown-code' }) + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + expect(res.body).not.toHaveProperty('token') + }) + + it('400 → ErrorBody on a malformed request (missing code)', async () => { + const res = await agent().post('/api/v1/security/session/exchange').send({}) + expect(res.status).toBe(400) + assertSchema('ErrorBody', res.body) + }) + }) +}) diff --git a/backend/security/tests/security-api/signup-methods.contract.test.ts b/backend/security/tests/security-api/signup-methods.contract.test.ts new file mode 100644 index 00000000..a01b3598 --- /dev/null +++ b/backend/security/tests/security-api/signup-methods.contract.test.ts @@ -0,0 +1,53 @@ +/** + * Contract tests: signup (server-brokered) + the neutral capability descriptor + * (/methods). Asserts status codes, schemas, and vendor-neutrality of /methods. + */ +import { agent, RUNNING_AGAINST } from './harness' +import { assertSchema, FORBIDDEN_VENDOR_TOKENS } from './spec' +import { SEED } from './mockIdentityProvider' + +describe(`signup + methods (against ${RUNNING_AGAINST})`, () => { + describe('POST /signup', () => { + it('201 → LoginResponse for a fresh account', async () => { + const email = `new-${Date.now()}@example.com` + const res = await agent() + .post('/api/v1/security/signup') + .send({ email, password: 'pw-123456', firstName: 'New', lastName: 'User' }) + expect(res.status).toBe(201) + assertSchema('LoginResponse', res.body) + expect(typeof res.body.token).toBe('string') + }) + + it('409 → ErrorBody when the email already exists', async () => { + const res = await agent() + .post('/api/v1/security/signup') + .send({ email: SEED.email, password: 'whatever-123' }) + expect(res.status).toBe(409) + assertSchema('ErrorBody', res.body) + }) + + it('400 → ErrorBody on a malformed body (missing password)', async () => { + const res = await agent().post('/api/v1/security/signup').send({ email: 'x@y.com' }) + expect(res.status).toBe(400) + assertSchema('ErrorBody', res.body) + }) + }) + + describe('GET /methods', () => { + it('200 → AuthMethods capability descriptor', async () => { + const res = await agent().get('/api/v1/security/methods') + expect(res.status).toBe(200) + assertSchema('AuthMethods', res.body) + }) + + it('names no vendor anywhere in the descriptor (neutrality)', async () => { + const res = await agent().get('/api/v1/security/methods') + const blob = JSON.stringify(res.body).toLowerCase() + for (const vendor of FORBIDDEN_VENDOR_TOKENS) { + expect(blob).not.toContain(vendor) + } + // Legacy vendor-specific boolean must be gone. + expect(res.body).not.toHaveProperty('oidcConfigured') + }) + }) +}) diff --git a/backend/security/tests/security-api/social.contract.test.ts b/backend/security/tests/security-api/social.contract.test.ts new file mode 100644 index 00000000..09ac81b7 --- /dev/null +++ b/backend/security/tests/security-api/social.contract.test.ts @@ -0,0 +1,77 @@ +/** + * Contract tests: server-brokered social login 302 semantics + the BOUNDARY + * guarantee (no internal identity host / vendor name ever visible to browser). + */ +import { agent, RUNNING_AGAINST } from './harness' +import { + assertSchema, + ALLOWED_SOCIAL_HOSTS, + FORBIDDEN_INTERNAL_HOST, + FORBIDDEN_VENDOR_TOKENS, +} from './spec' + +function hostOf(location: string): string { + // Location may be absolute or app-relative; only absolute has a host. + try { + return new URL(location).host + } catch { + return '' // relative → same-origin (app.fuzefront.com), inherently owned + } +} + +describe(`social login 302 + boundary (against ${RUNNING_AGAINST})`, () => { + describe('GET /social/{provider}/start', () => { + it('302-redirects to a FuzeFront-owned or Google host only', async () => { + const res = await agent().get('/api/v1/security/social/google/start').redirects(0) + expect(res.status).toBe(302) + const loc = res.headers['location'] + expect(typeof loc).toBe('string') + + const host = hostOf(loc) + if (host) { + expect(ALLOWED_SOCIAL_HOSTS.has(host)).toBe(true) + } + // The internal identity host must NEVER appear. + expect(loc.toLowerCase()).not.toContain(FORBIDDEN_INTERNAL_HOST) + for (const vendor of FORBIDDEN_VENDOR_TOKENS) { + expect(loc.toLowerCase()).not.toContain(vendor) + } + }) + + it('rejects an absolute (cross-origin) redirectTo with 400', async () => { + const res = await agent() + .get('/api/v1/security/social/google/start') + .query({ redirectTo: 'https://evil.example.com/phish' }) + .redirects(0) + expect(res.status).toBe(400) + assertSchema('ErrorBody', res.body) + }) + + it('400 → ErrorBody for an unsupported provider slug', async () => { + const res = await agent().get('/api/v1/security/social/myspace/start').redirects(0) + expect(res.status).toBe(400) + assertSchema('ErrorBody', res.body) + }) + }) + + describe('GET /social/callback', () => { + it('302s back to the app with an opaque ?code= (no token in URL)', async () => { + const res = await agent() + .get('/api/v1/security/social/callback') + .query({ code: 'provider-auth-code', state: 'valid-state' }) + .redirects(0) + expect(res.status).toBe(302) + const loc = res.headers['location'] + expect(loc).toContain('code=') + // No session token ever placed in the redirect URL. + expect(loc.toLowerCase()).not.toMatch(/token=/) + expect(loc.toLowerCase()).not.toContain(FORBIDDEN_INTERNAL_HOST) + }) + + it('401 → ErrorBody when required params are missing (fail-closed)', async () => { + const res = await agent().get('/api/v1/security/social/callback').redirects(0) + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + }) + }) +}) diff --git a/backend/security/tests/security-api/spec.ts b/backend/security/tests/security-api/spec.ts new file mode 100644 index 00000000..2757b955 --- /dev/null +++ b/backend/security/tests/security-api/spec.ts @@ -0,0 +1,111 @@ +/** + * spec.ts — loads the FROZEN OpenAPI contract (packages/security/openapi.yaml) + * and exposes an Ajv-based response/request schema validator. + * + * This is the source of truth for the INDEPENDENT AuthN verification suite. + * Tests assert the contract, never the implementation's internals. + */ +import * as fs from 'fs' +import * as path from 'path' +import * as yaml from 'js-yaml' +import Ajv2020, { ValidateFunction } from 'ajv/dist/2020' +import addFormats from 'ajv-formats' + +export const SPEC_PATH = path.resolve( + __dirname, + '../../../../packages/security/openapi.yaml' +) + +export const spec: any = yaml.load(fs.readFileSync(SPEC_PATH, 'utf8')) + +const ajv = new Ajv2020({ + allErrors: true, + strict: false, + // Contract is same-origin JSON; discriminated oneOf handled structurally. +}) +addFormats(ajv) +ajv.addSchema(spec, 'spec') + +const compiledCache = new Map() + +/** Compile (and cache) a validator for a named component schema. */ +export function validatorFor(schemaName: string): ValidateFunction { + const key = `spec#/components/schemas/${schemaName}` + let v = compiledCache.get(key) + if (!v) { + v = ajv.compile({ $ref: key }) + compiledCache.set(key, v) + } + return v +} + +/** + * Assert `data` conforms to component schema `schemaName`. + * Throws a readable error (with Ajv errors) on failure — a failing contract + * assertion is a valid, reportable deliverable. + */ +export function assertSchema(schemaName: string, data: unknown): void { + const validate = validatorFor(schemaName) + const ok = validate(data) + if (!ok) { + throw new Error( + `Response does not conform to schema "${schemaName}":\n` + + JSON.stringify(validate.errors, null, 2) + + `\n--- payload ---\n` + + JSON.stringify(data, null, 2) + ) + } +} + +/** Convenience matcher wrapper for Jest expect(). */ +export function conformsTo(schemaName: string, data: unknown): boolean { + const validate = validatorFor(schemaName) + return validate(data) === true +} + +/** The two hosts a browser may EVER transit for AuthN (boundary guarantee). */ +export const FUZEFRONT_OWNED_HOST = 'app.fuzefront.com' +export const ALLOWED_SOCIAL_HOSTS = new Set([ + FUZEFRONT_OWNED_HOST, + 'accounts.google.com', // Google's own consent host — the one unavoidable hop +]) + +/** Hosts / vendor names that must NEVER appear in any AuthN response. */ +export const FORBIDDEN_INTERNAL_HOST = 'auth.fuzefront.com' +export const FORBIDDEN_VENDOR_TOKENS = ['authentik', 'permit', 'permitio', 'okta', 'auth0'] + +/** + * Every path in the spec + whether it is pagination-exempt, extracted straight + * from the frozen contract so the pagination gate can't drift from it. + */ +export interface EndpointFlag { + path: string + method: string + operationId?: string + paginated: boolean + exempt: boolean + exemptReason?: string +} + +export function listEndpoints(): EndpointFlag[] { + const out: EndpointFlag[] = [] + for (const [p, ops] of Object.entries(spec.paths)) { + for (const [method, op] of Object.entries(ops)) { + if (!['get', 'post', 'put', 'delete', 'patch'].includes(method)) continue + const exempt = op['x-pagination'] === 'exempt' + // A paginated endpoint declares Limit + Cursor params (family standard). + const params = (op.parameters || []).map((x: any) => x.$ref || '') + const hasLimit = params.some((r: string) => r.endsWith('/Limit')) + const hasCursor = params.some((r: string) => r.endsWith('/Cursor')) + out.push({ + path: p, + method, + operationId: op.operationId, + paginated: hasLimit && hasCursor && !exempt, + exempt, + exemptReason: op['x-pagination-reason'], + }) + } + } + return out +} diff --git a/backend/security/tests/security-api/tokens.contract.test.ts b/backend/security/tests/security-api/tokens.contract.test.ts new file mode 100644 index 00000000..28b55fb1 --- /dev/null +++ b/backend/security/tests/security-api/tokens.contract.test.ts @@ -0,0 +1,59 @@ +/** + * Contract tests: M2M token issuance + introspection. Fail-closed introspection + * (unknown/expired → { active: false }) is the headline assertion. + */ +import { agent, RUNNING_AGAINST } from './harness' +import { assertSchema } from './spec' +import { SEED } from './mockIdentityProvider' + +describe(`m2m tokens (against ${RUNNING_AGAINST})`, () => { + describe('POST /tokens — issue', () => { + it('200 → TokenIssueResponse for valid client credentials', async () => { + const res = await agent() + .post('/api/v1/security/tokens') + .send({ clientId: SEED.m2mClientId, clientSecret: SEED.m2mClientSecret, scope: 'read' }) + expect(res.status).toBe(200) + assertSchema('TokenIssueResponse', res.body) + expect(res.body.tokenType).toBe('Bearer') + expect(typeof res.body.accessToken).toBe('string') + }) + + it('401 → ErrorBody on bad client credentials (fail-closed)', async () => { + const res = await agent() + .post('/api/v1/security/tokens') + .send({ clientId: SEED.m2mClientId, clientSecret: 'wrong' }) + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + expect(res.body).not.toHaveProperty('accessToken') + }) + + it('400 → ErrorBody on malformed body', async () => { + const res = await agent().post('/api/v1/security/tokens').send({ clientId: 'x' }) + expect(res.status).toBe(400) + assertSchema('ErrorBody', res.body) + }) + }) + + describe('POST /tokens/introspect', () => { + it('200 → { active: true } for a freshly issued token', async () => { + const issued = await agent() + .post('/api/v1/security/tokens') + .send({ clientId: SEED.m2mClientId, clientSecret: SEED.m2mClientSecret }) + const res = await agent() + .post('/api/v1/security/tokens/introspect') + .send({ token: issued.body.accessToken }) + expect(res.status).toBe(200) + assertSchema('TokenIntrospection', res.body) + expect(res.body.active).toBe(true) + }) + + it('200 → { active: false } for an unknown token (FAIL-CLOSED, never permissive)', async () => { + const res = await agent() + .post('/api/v1/security/tokens/introspect') + .send({ token: 'unknown-token-xyz' }) + expect(res.status).toBe(200) + assertSchema('TokenIntrospection', res.body) + expect(res.body.active).toBe(false) + }) + }) +}) diff --git a/backend/security/tests/security-api/verify.contract.test.ts b/backend/security/tests/security-api/verify.contract.test.ts new file mode 100644 index 00000000..f3cf5534 --- /dev/null +++ b/backend/security/tests/security-api/verify.contract.test.ts @@ -0,0 +1,92 @@ +/** + * Contract tests: contact-ownership verification (email + phone) + status. + */ +import { agent, RUNNING_AGAINST } from './harness' +import { assertSchema } from './spec' +import { SEED } from './mockIdentityProvider' + +async function token() { + const res = await agent() + .post('/api/v1/security/session') + .send({ email: SEED.email, password: SEED.password }) + return res.body.token as string +} + +describe(`contact verification (against ${RUNNING_AGAINST})`, () => { + describe('email', () => { + it('POST /verify/email/start (signup-scoped address) → 202', async () => { + const res = await agent() + .post('/api/v1/security/verify/email/start') + .send({ email: 'pending@example.com' }) + expect(res.status).toBe(202) + }) + + it('POST /verify/email/confirm with a valid OTP → 200 VerificationStatus', async () => { + const res = await agent() + .post('/api/v1/security/verify/email/confirm') + .send({ code: '000000' }) + expect(res.status).toBe(200) + assertSchema('VerificationStatus', res.body) + expect(res.body.emailVerified).toBe(true) + }) + + it('POST /verify/email/confirm with a bad token/code → 400 (fail-closed)', async () => { + const res = await agent() + .post('/api/v1/security/verify/email/confirm') + .send({ code: '999999' }) + expect(res.status).toBe(400) + assertSchema('ErrorBody', res.body) + }) + }) + + describe('phone', () => { + it('POST /verify/phone/start requires auth (401 without token)', async () => { + const res = await agent() + .post('/api/v1/security/verify/phone/start') + .send({ phone: '+15550001111' }) + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + }) + + it('POST /verify/phone/start (authed) → 202', async () => { + const t = await token() + const res = await agent() + .post('/api/v1/security/verify/phone/start') + .set('Authorization', `Bearer ${t}`) + .send({ phone: '+15550001111' }) + expect(res.status).toBe(202) + }) + + it('POST /verify/phone/confirm valid → 200; bad code → 400', async () => { + const ok = await agent() + .post('/api/v1/security/verify/phone/confirm') + .send({ phone: '+15550001111', code: '000000' }) + expect(ok.status).toBe(200) + assertSchema('VerificationStatus', ok.body) + expect(ok.body.phoneVerified).toBe(true) + + const bad = await agent() + .post('/api/v1/security/verify/phone/confirm') + .send({ phone: '+15550001111', code: '424242' }) + expect(bad.status).toBe(400) + assertSchema('ErrorBody', bad.body) + }) + }) + + describe('status', () => { + it('GET /verify/status (authed) → 200 VerificationStatus', async () => { + const t = await token() + const res = await agent() + .get('/api/v1/security/verify/status') + .set('Authorization', `Bearer ${t}`) + expect(res.status).toBe(200) + assertSchema('VerificationStatus', res.body) + }) + + it('GET /verify/status without token → 401', async () => { + const res = await agent().get('/api/v1/security/verify/status') + expect(res.status).toBe(401) + assertSchema('ErrorBody', res.body) + }) + }) +})