diff --git a/.gitignore b/.gitignore index 1fa43b5..ff82018 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ installer/nssm.zip installer/checksums-msi.txt .next *.tsbuildinfo +web/test-results +web/playwright-report +web/blob-report +web/playwright/.cache graphify-out docker-compose.live.yml .claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 1008c16..2732f12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -757,6 +757,93 @@ reference that lies. Scalar is vendored (`scalar.standalone.js`, served from reference page has to work on an air-gapped install with no outbound access at all - the same requirement licence verification already meets. +### Multi-factor authentication + +Local and `hq`-sourced members can enrol TOTP and WebAuthn passkeys; OIDC users +are exempt (`auth_source == "oidc"`), since their IdP owns authentication. + +**A password that checks out mints a pending-login ticket, not a session with +an `mfa_pending` flag.** The ticket is a Redis key (`km:mfa:`, 5 min TTL) +referenced by a separate `km_mfa_pending` cookie; only the `/auth/mfa/*` and +`/auth/mfa/enrol/*` endpoints accept it, and it exchanges for a `km_session` +only on a completed second factor or completed enrolment. A flag on `Session` +would fail open - any route mounted under `auth.Middleware`, today's or a +future one, could serve a half-authenticated user by forgetting to check it. +A ticket fails closed: nothing under `auth.Middleware` recognises it at all, +because it is never a `*Session`. `Session` itself gained `AMR []string` +(`pwd`, `otp`, `webauthn`, `recovery`, `oidc`) and `StepUpAt *time.Time`; sign-in +counts as a step-up, so `StepUpAt` is set at session creation. + +**The pending ticket's attempt counter is an atomic Redis counter +(`km:mfa::attempts`), not a field rewritten on the ticket document.** Two +requests racing to fail a guess would otherwise both read the same `attempts` +and both write it back incremented once, undercounting. `INCR` has no such +race; the fifth failure deletes the ticket. + +**The TOTP replay guard is keyed on the time step, not the code**: +`km:totp::`, `SET NX` with a 90s TTL. Keying on the code itself +would let the same 6 digits be replayed across two different steps that +happen to compute it (a 1-in-a-million collision, but a free one to close); +keying on the step means a given 30-second window can be spent exactly once, +which is what "single-use" actually means for a TOTP code. + +**`user_mfa.totp_pending_enc` holds an unconfirmed TOTP secret and is +deliberately not in `vantage-shared`'s `backup.ciphertextFields["user_mfa"]`**, +which lists only `totp_secret_enc`. The confirmed secret is the one that +authenticates anyone; an abandoned setup attempt (scanned once, never +confirmed, replaced by the next `POST /me/mfa/totp/setup` call) is not worth +widening the backup contract's surface for. `vantagectl verify`'s live probe +therefore never touches it - this is intentional, not the same silent gap the +ciphertext-field mirror otherwise guards against. + +**Two new collections**, both in `ScopedCollections` so an instance purge +removes them, both with a fatal index builder like `EnsureAuthIndexes`: + +- `user_mfa` - one document per user who has started enrolment. Unique index + `{instance_id, user_id}`. `totp_confirmed_at: nil` means setup started but + TOTP is not active; "has MFA" means that field is set or the user owns a + passkey. +- `webauthn_credentials` - one document per passkey. Unique index + `{instance_id, credential_id}`, plus `{instance_id, user_id}`. `sign_count` + backs clone detection: a non-increasing non-zero count fails the assertion. + +`require_mfa` (`models.Settings.RequireMFA bool`, `bson:"require_mfa"`) +shipped in `vantage-shared` v0.7.0 - a plain bool because absent must mean off. +Switching it on does not revoke existing sessions; they end at their normal +24h TTL, and the next sign-in enforces enrolment for anyone with no factor yet. + +**WebAuthn's RP ID is the request host with any port stripped**, resolved +per-request rather than configured, the same way the org/host guard resolves +an instance from `.vantage.`. A passkey is bound to the host it was +registered on: moving a self-hosted instance to a new domain, or renaming a +cloud instance (see "A rename moves the host" above), invalidates every +passkey on it. TOTP and recovery codes are unaffected, since they carry no +host binding. The docs say so; there is no migration path for a passkey +across a host change. + +**Step-up** (`auth.RequireStepUp()`) gates three existing sensitive routes - +`POST /api/secrets/:group/reveal`, `GET /api/keys/:id/private-key`, +`POST /api/console/connect` - plus the MFA-management endpoints that create or +remove a factor. It passes when `StepUpAt` is within the last **ten minutes**, +when the session's `AMR` contains `oidc` (the IdP's own session policy +governs), or **when the request authenticated with an API token** +(`TokenID != ""`). That last exemption is a known, accepted gap, not an +oversight: a token has no human present to prompt for a second factor, so a +token holding `secrets:read` or `keys:read` can reveal a secret or download a +private key with no re-authentication at all. The mitigation is scoped, +short-lived tokens, tracked separately in the gap review, not a code change +here - a later reviewer should not "fix" this silently. `POST /api/me/step-up` +takes `{totp}`, `{recovery}` or `{password}` (password only for a user with no +MFA); `POST /api/me/step-up/webauthn/begin` and `/finish` do the same with a +passkey. All three, like every unauthenticated MFA endpoint, sit behind +`RateLimitAuth()` - a fixed Redis window, 20 requests/minute per +`c.ClientIP()`, answering 429 with `Retry-After` - on the `RateLimitTokens` +pattern but for sign-in and re-authentication rather than API tokens. + +Library versions: `github.com/pquerna/otp` for TOTP, `github.com/go-webauthn/webauthn` +**v0.18.1** for WebAuthn ceremonies, `qrcode` (npm) to draw the enrolment QR +client-side so an air-gapped install needs nothing external. + ### The public host **vantage.hostxtra.co.uk is not served by this repository.** The marketing site @@ -967,6 +1054,11 @@ POST /auth/bootstrap /auth/login /auth/logout GET /auth/me GET /auth/providers # {local_enabled, providers:[{id,name,preset}]} - no issuer, client ID or secret GET /api/secrets/:group/values # bearer token (ESO) +POST /auth/mfa/totp /auth/mfa/recovery # second factor against a pending-login ticket +POST /auth/mfa/webauthn/begin /finish +POST /auth/passkey/begin /auth/passkey/finish # passwordless sign-in +POST /auth/mfa/enrol/totp/setup /confirm # ticket-scoped forced enrolment +POST /auth/mfa/enrol/passkey/begin /finish ``` Session-authed under `/api`: @@ -1012,6 +1104,12 @@ agent GET /agent/latest-version settings GET,PUT /settings · POST /settings/secrets-token (owner|admin) licence GET /license · POST /license (POST: self-hosted only) org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id + DELETE /org/users/:id/mfa (owner|admin, step-up) +mfa GET /me/mfa · POST /me/mfa/totp/setup (step-up) + POST /me/mfa/totp/confirm · DELETE /me/mfa/totp (step-up) + POST /me/mfa/recovery/regenerate (step-up) + POST,PATCH,DELETE /me/passkeys[/begin,/finish,/:id] (step-up, except rename) + POST /me/step-up · POST /me/step-up/webauthn/begin /finish providers GET,POST /auth/providers · PUT,DELETE /auth/providers/:id POST /auth/providers/:id/{test,ack-notice} · GET /auth/presets (owner|admin) tokens GET /tokens · GET /tokens/scopes · POST /tokens · DELETE /tokens/:id @@ -1067,7 +1165,7 @@ plane, each of which this codebase enforces: ## MongoDB Collections -`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `maintenance_windows` · `patch_policies` · `patch_runs` · `patch_run_outputs` · `migrations` +`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `api_tokens` · `status_pages` · `status_incidents` · `maintenance_windows` · `patch_policies` · `patch_runs` · `patch_run_outputs` · `user_mfa` · `webauthn_credentials` · `migrations` Every document except `migrations` carries `org_id`. Struct definitions are the source of truth - see `server/internal/models/`. diff --git a/server/internal/api/docs/openapi.json b/server/internal/api/docs/openapi.json index c3baa9e..80b950b 100644 --- a/server/internal/api/docs/openapi.json +++ b/server/internal/api/docs/openapi.json @@ -1876,6 +1876,10 @@ "description": "LocalLoginEnabled is a pointer because it is absent on every settings\ndocument written before this feature existed, and a plain bool would read\nabsent as disabled - turning off password login for the entire fleet at\nupgrade. Nil means enabled.", "type": "boolean" }, + "require_mfa": { + "description": "RequireMFA forces every password-authenticated member to hold a second\nfactor. A pointer for the same reason LocalLoginEnabled is: absent must\nmean off, and a plain bool read from an old document would lock out an\nentire instance at upgrade.", + "type": "boolean" + }, "secrets": { "$ref": "#/components/schemas/models.SecretsSettings" }, @@ -2242,6 +2246,31 @@ }, "type": "object" }, + "models.WebAuthnCredential": { + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "description": "CredentialIDHex is the browser-facing identifier for rename and delete.\nThe raw bytes never reach a URL.", + "type": "string" + }, + "last_used_at": { + "type": "string" + }, + "name": { + "type": "string" + }, + "transports": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": false + } + }, + "type": "object" + }, "models.Workflow": { "properties": { "created_at": { @@ -2826,6 +2855,613 @@ ] } }, + "/auth/mfa/enrol/passkey/begin": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ceremony_id": { + "type": "string" + }, + "publicKey": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Begin forced passkey enrolment during sign-in", + "tags": [ + "auth" + ] + } + }, + "/auth/mfa/enrol/passkey/finish": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "ceremony_id": { + "type": "string" + }, + "credential": { + "type": "object" + }, + "name": { + "type": "string" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Attestation", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "recovery_codes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Complete forced passkey enrolment and sign in", + "tags": [ + "auth" + ] + } + }, + "/auth/mfa/enrol/totp/confirm": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "code": { + "type": "string" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Six-digit code", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "recovery_codes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Confirm forced TOTP enrolment and sign in", + "tags": [ + "auth" + ] + } + }, + "/auth/mfa/enrol/totp/setup": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "otpauth_uri": { + "type": "string" + }, + "secret": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Start forced TOTP enrolment during sign-in", + "tags": [ + "auth" + ] + } + }, + "/auth/mfa/recovery": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "code": { + "type": "string" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Recovery code", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Complete sign-in with a recovery code", + "tags": [ + "auth" + ] + } + }, + "/auth/mfa/totp": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "code": { + "type": "string" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Six-digit code", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Complete sign-in with a TOTP code", + "tags": [ + "auth" + ] + } + }, + "/auth/mfa/webauthn/begin": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ceremony_id": { + "type": "string" + }, + "publicKey": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Begin passkey verification during sign-in", + "tags": [ + "auth" + ] + } + }, + "/auth/mfa/webauthn/finish": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "ceremony_id": { + "type": "string" + }, + "credential": { + "type": "object" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Assertion", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Complete sign-in with a passkey", + "tags": [ + "auth" + ] + } + }, + "/auth/passkey/begin": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ceremony_id": { + "type": "string" + }, + "publicKey": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "Begin passwordless passkey sign-in", + "tags": [ + "auth" + ] + } + }, + "/auth/passkey/finish": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "ceremony_id": { + "type": "string" + }, + "credential": { + "type": "object" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Assertion", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Complete passwordless passkey sign-in", + "tags": [ + "auth" + ] + } + }, "/auth/presets": { "get": { "description": "Preset providers (Entra, Google, Okta, GitHub) that expand to a real issuer on save.", @@ -3813,45 +4449,6 @@ } }, "/instance/users": { - "get": { - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/models.User" - }, - "type": "array" - } - } - }, - "description": "OK" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/api.ErrorResponse" - } - } - }, - "description": "Internal Server Error" - } - }, - "security": [ - { - "cookieAuth": [] - }, - { - "bearerAuth": [] - } - ], - "summary": "List instance members", - "tags": [ - "instance-users" - ] - }, "post": { "description": "Only an owner can create another owner.", "requestBody": { @@ -4978,6 +5575,587 @@ ] } }, + "/me/mfa": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "applicable": { + "type": "boolean" + }, + "passkeys": { + "items": { + "$ref": "#/components/schemas/models.WebAuthnCredential" + }, + "type": "array" + }, + "recovery_remaining": { + "type": "integer" + }, + "require_mfa": { + "type": "boolean" + }, + "totp_enabled": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Get my MFA status", + "tags": [ + "mfa" + ] + } + }, + "/me/mfa/recovery/regenerate": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "recovery_codes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Regenerate recovery codes", + "tags": [ + "mfa" + ] + } + }, + "/me/mfa/totp": { + "delete": { + "responses": { + "204": { + "description": "No Content" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Conflict" + } + }, + "summary": "Remove TOTP", + "tags": [ + "mfa" + ] + } + }, + "/me/mfa/totp/confirm": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "code": { + "type": "string" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Six-digit code", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "recovery_codes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Confirm TOTP setup", + "tags": [ + "mfa" + ] + } + }, + "/me/mfa/totp/setup": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "otpauth_uri": { + "type": "string" + }, + "secret": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Conflict" + } + }, + "summary": "Start TOTP setup", + "tags": [ + "mfa" + ] + } + }, + "/me/passkeys/begin": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ceremony_id": { + "type": "string" + }, + "publicKey": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Begin passkey registration", + "tags": [ + "mfa" + ] + } + }, + "/me/passkeys/finish": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "ceremony_id": { + "type": "string" + }, + "credential": { + "type": "object" + }, + "name": { + "type": "string" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Attestation", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + }, + "recovery_codes": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + } + } + }, + "description": "OK" + } + }, + "summary": "Complete passkey registration", + "tags": [ + "mfa" + ] + } + }, + "/me/passkeys/{id}": { + "delete": { + "parameters": [ + { + "description": "Credential ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "409": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Conflict" + } + }, + "summary": "Delete a passkey", + "tags": [ + "mfa" + ] + }, + "patch": { + "parameters": [ + { + "description": "Credential ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "New name", + "required": true + }, + "responses": { + "204": { + "description": "No Content" + } + }, + "summary": "Rename a passkey", + "tags": [ + "mfa" + ] + } + }, + "/me/step-up": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "password": { + "type": "string" + }, + "recovery": { + "type": "string" + }, + "totp": { + "type": "string" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "One factor", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Re-authenticate before a sensitive action", + "tags": [ + "mfa" + ] + } + }, + "/me/step-up/webauthn/begin": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ceremony_id": { + "type": "string" + }, + "publicKey": { + "type": "object" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Bad Request" + } + }, + "summary": "Begin passkey step-up", + "tags": [ + "mfa" + ] + } + }, + "/me/step-up/webauthn/finish": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "object" + }, + { + "properties": { + "ceremony_id": { + "type": "string" + }, + "credential": { + "type": "object" + } + }, + "title": "body", + "type": "object" + } + ] + } + } + }, + "description": "Assertion", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "ok": { + "type": "boolean" + } + }, + "type": "object" + } + } + }, + "description": "OK" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Unauthorized" + } + }, + "summary": "Complete passkey step-up", + "tags": [ + "mfa" + ] + } + }, "/monitors": { "get": { "responses": { @@ -5521,6 +6699,45 @@ ] } }, + "/org/users/{id}/mfa": { + "delete": { + "parameters": [ + { + "description": "User ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "error": { + "type": "string" + } + }, + "type": "object" + } + } + }, + "description": "Forbidden" + } + }, + "summary": "Reset another member's MFA", + "tags": [ + "mfa" + ] + } + }, "/packages/search": { "get": { "parameters": [ diff --git a/web/e2e/mfa.spec.ts b/web/e2e/mfa.spec.ts new file mode 100644 index 0000000..4ac0c47 --- /dev/null +++ b/web/e2e/mfa.spec.ts @@ -0,0 +1,237 @@ +import { test, expect, type APIRequestContext, type BrowserContext, type Page } from "@playwright/test"; +import { authenticator } from "otplib"; + +/** + * End-to-end coverage for Task 15 of the MFA design + * (docs/superpowers/specs/2026-09-15-mfa-local-signin-design.md). + * + * Prerequisites: a running control plane (server + web) backed by MongoDB + * and Redis, reachable at `E2E_BASE_URL` (default http://localhost:3000) + * with the API at the same origin (the nginx fragment in + * deploy/docker/docker-compose.yml, or an equivalent dev proxy). + * + * The suite needs an existing owner session to create fresh member accounts + * per test, so state never leaks between tests. If the instance has never + * been bootstrapped, the first test bootstraps it and every other test reuses + * those owner credentials; set E2E_OWNER_EMAIL / E2E_OWNER_PASSWORD to point + * at an already-bootstrapped instance's owner instead. + */ + +const BASE_URL = process.env.E2E_BASE_URL ?? "http://localhost:3000"; + +let ownerEmail = process.env.E2E_OWNER_EMAIL ?? ""; +let ownerPassword = process.env.E2E_OWNER_PASSWORD ?? ""; + +/** Ensures an owner account exists and returns its credentials, bootstrapping the instance if needed. */ +async function ensureOwner(request: APIRequestContext): Promise<{ email: string; password: string }> { + if (ownerEmail && ownerPassword) return { email: ownerEmail, password: ownerPassword }; + + const status = await request.get(`${BASE_URL}/auth/bootstrap-status`); + const body = await status.json(); + if (!body.needs_setup) { + throw new Error( + "Instance is already bootstrapped and E2E_OWNER_EMAIL/E2E_OWNER_PASSWORD were not set - " + + "cannot create the owner account this suite needs to provision fresh members per test.", + ); + } + + ownerEmail = `e2e-owner-${Date.now()}@vantage.test`; + ownerPassword = "correct horse battery staple 1"; + const res = await request.post(`${BASE_URL}/auth/bootstrap`, { + data: { instance_name: "MFA E2E", email: ownerEmail, password: ownerPassword }, + }); + if (!res.ok()) throw new Error(`bootstrap failed: ${res.status()} ${await res.text()}`); + return { email: ownerEmail, password: ownerPassword }; +} + +/** Creates a fresh, unique local member with no MFA enrolled, using an owner session. */ +async function createMember(request: APIRequestContext): Promise<{ email: string; password: string }> { + const owner = await ensureOwner(request); + + const login = await request.post(`${BASE_URL}/auth/login`, { + data: { email: owner.email, password: owner.password }, + }); + if (!login.ok()) throw new Error(`owner login failed: ${login.status()} ${await login.text()}`); + + const email = `e2e-member-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@vantage.test`; + const password = "correct horse battery staple 1"; + const res = await request.post(`${BASE_URL}/api/org/users`, { + data: { email, password, role: "member" }, + }); + if (!res.ok()) throw new Error(`create member failed: ${res.status()} ${await res.text()}`); + + await request.post(`${BASE_URL}/auth/logout`); + return { email, password }; +} + +/** Registers Chrome's virtual authenticator (CTAP2, resident keys, internal UV) on this context. */ +async function addVirtualAuthenticator(context: BrowserContext, page: Page): Promise { + const client = await context.newCDPSession(page); + await client.send("WebAuthn.enable"); + const { authenticatorId } = await client.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }); + return authenticatorId; +} + +async function fillCredentials(page: Page, email: string, password: string) { + await page.goto("/login"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Password").fill(password); + await page.getByRole("button", { name: "Sign In" }).click(); +} + +test.describe("MFA end-to-end", () => { + test("TOTP sign-in", async ({ page, request }) => { + const { email, password } = await createMember(request); + + // Sign in, land on the account security page, enrol TOTP. + await fillCredentials(page, email, password); + await expect(page).toHaveURL("/"); + + await page.goto("/account/security"); + await page.getByRole("button", { name: "Set up" }).click(); + await page.getByRole("button", { name: "Use an authenticator app" }).click(); + + const secret = await page.locator("p.font-mono").innerText(); + const code = authenticator.generate(secret.trim()); + await page.getByLabel("6-digit code").fill(code); + await page.getByRole("button", { name: "Confirm" }).click(); + + // Recovery codes step - acknowledge and finish. + await page.getByLabel("I have saved these codes").check(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("Enabled")).toBeVisible(); + + // Sign out and sign back in - TOTP is now required. + await page.request.post(`${BASE_URL}/auth/logout`); + await fillCredentials(page, email, password); + + await expect(page.getByText("Enter the 6-digit code")).toBeVisible(); + const nextCode = authenticator.generate(secret.trim()); + await page.getByLabel("Verification code").fill(nextCode); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page).toHaveURL("/"); + }); + + test("passkey second factor", async ({ page, context, request }) => { + const { email, password } = await createMember(request); + await addVirtualAuthenticator(context, page); + + await fillCredentials(page, email, password); + await expect(page).toHaveURL("/"); + + await page.goto("/account/security"); + await page.getByRole("button", { name: "Set up" }).click(); + await page.getByRole("button", { name: "Use a passkey" }).click(); + + await page.getByLabel("I have saved these codes").check(); + await page.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("No passkeys registered.")).toHaveCount(0); + + await page.request.post(`${BASE_URL}/auth/logout`); + await fillCredentials(page, email, password); + + await expect(page.getByText("Enter the 6-digit code")).toBeVisible(); + await page.getByRole("button", { name: "Use passkey" }).click(); + + await expect(page).toHaveURL("/"); + }); + + test("passwordless passkey sign-in", async ({ page, context, request }) => { + const { email, password } = await createMember(request); + await addVirtualAuthenticator(context, page); + + // Enrol a passkey first (typing the password once, during setup only). + await fillCredentials(page, email, password); + await expect(page).toHaveURL("/"); + await page.goto("/account/security"); + await page.getByRole("button", { name: "Add a passkey" }).click(); + await page.request.post(`${BASE_URL}/auth/logout`); + + // Sign back in with no password typed at all. + await page.goto("/login"); + await page.getByRole("button", { name: "Sign in with passkey" }).click(); + + await expect(page).toHaveURL("/"); + }); + + test("forced enrolment when require_mfa is on", async ({ page, request }) => { + const owner = await ensureOwner(request); + const { email, password } = await createMember(request); + + // Owner enables the policy. + const ownerLogin = await request.post(`${BASE_URL}/auth/login`, { + data: { email: owner.email, password: owner.password }, + }); + expect(ownerLogin.ok()).toBeTruthy(); + const settingsRes = await request.put(`${BASE_URL}/api/settings`, { + data: { require_mfa: true }, + }); + expect(settingsRes.ok()).toBeTruthy(); + await request.post(`${BASE_URL}/auth/logout`); + + // The member, who has no MFA yet, must enrol before reaching the app. + await fillCredentials(page, email, password); + await expect(page.getByText("This instance requires a second sign-in factor. Set one up to continue.")).toBeVisible(); + + await page.getByRole("button", { name: "Use an authenticator app" }).click(); + const secret = await page.locator("p.font-mono").innerText(); + const code = authenticator.generate(secret.trim()); + await page.getByLabel("6-digit code").fill(code); + await page.getByRole("button", { name: "Confirm" }).click(); + await page.getByLabel("I have saved these codes").check(); + await page.getByRole("button", { name: "Continue" }).click(); + + await expect(page).toHaveURL("/"); + + // Clean up: turn the policy back off so it doesn't affect other tests. + await request.post(`${BASE_URL}/auth/login`, { data: { email: owner.email, password: owner.password } }); + await request.put(`${BASE_URL}/api/settings`, { data: { require_mfa: false } }); + await request.post(`${BASE_URL}/auth/logout`); + }); + + test("step-up on secret reveal", async ({ page, request }) => { + const { email, password } = await createMember(request); + + // Enrol TOTP so step-up has a factor to challenge. + await fillCredentials(page, email, password); + await page.goto("/account/security"); + await page.getByRole("button", { name: "Set up" }).click(); + await page.getByRole("button", { name: "Use an authenticator app" }).click(); + const secret = await page.locator("p.font-mono").innerText(); + await page.getByLabel("6-digit code").fill(authenticator.generate(secret.trim())); + await page.getByRole("button", { name: "Confirm" }).click(); + await page.getByLabel("I have saved these codes").check(); + await page.getByRole("button", { name: "Continue" }).click(); + + // A brand-new session's StepUpAt is fresh from sign-in, so create a secret, + // then create a group via the UI and attempt a reveal - the modal should + // still appear because sign-in only counts as step-up for ten minutes and + // this test does not wait that long; it is testing the prompt fires the + // first time an authenticated caller with no fresh step-up reveals one. + await request.post(`${BASE_URL}/api/secrets`, { data: { group: "e2e-stepup", values: { KEY: "value" } } }); + + await page.goto("/secrets/e2e-stepup"); + await page.getByRole("button", { name: "Reveal" }).click(); + + await expect(page.getByText("Confirm it's you")).toBeVisible(); + await page.getByLabel("6-digit code").fill(authenticator.generate(secret.trim())); + await page.getByRole("button", { name: "Confirm" }).click(); + + await expect(page.getByText("Confirm it's you")).toHaveCount(0); + await expect(page.locator("span.font-mono.text-xs.break-all")).toBeVisible(); + + // Reveal again within the ten-minute step-up window: no prompt this time. + await page.getByRole("button", { name: "Hide" }).click().catch(() => {}); + await page.getByRole("button", { name: "Reveal" }).click(); + await expect(page.getByText("Confirm it's you")).toHaveCount(0); + }); +}); diff --git a/web/package-lock.json b/web/package-lock.json index e2bf91a..d4bd9ff 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,6 +17,7 @@ "tailwind-merge": "^2.4.0" }, "devDependencies": { + "@playwright/test": "^1.48.0", "@types/node": "^20.14.11", "@types/qrcode": "^1.5.6", "@types/react": "^18.3.3", @@ -24,6 +25,7 @@ "autoprefixer": "^10.4.19", "eslint": "^9.0.0", "eslint-config-next": "16.2.9", + "otplib": "^12.0.1", "postcss": "^8.4.39", "tailwindcss": "^3.4.6", "typescript": "^5.5.3" @@ -1231,6 +1233,78 @@ "node": ">=12.4.0" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "dev": true, + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "dev": true, + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "dev": true, + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -5133,6 +5207,18 @@ "node": ">= 0.8.0" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -5270,6 +5356,35 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -6420,6 +6535,15 @@ "node": ">=0.8" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "dev": true, + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", diff --git a/web/package.json b/web/package.json index 3dcd0d0..78be45d 100644 --- a/web/package.json +++ b/web/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:e2e": "playwright test" }, "dependencies": { "@tanstack/react-query": "^5.51.1", @@ -18,6 +19,8 @@ "tailwind-merge": "^2.4.0" }, "devDependencies": { + "@playwright/test": "^1.48.0", + "otplib": "^12.0.1", "@types/node": "^20.14.11", "@types/qrcode": "^1.5.6", "@types/react": "^18.3.3", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..c6fa6cc --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Minimal Playwright setup for the MFA end-to-end coverage (Task 15). + * + * There was no Playwright config in this repository before this task, so + * this is the smallest one that works: one project (Chromium, the only + * browser that implements the WebAuthn virtual authenticator CDP domain the + * passkey tests need), pointed at an already-running stack. + * + * Prerequisites (not started by this config - the stack needs MongoDB and + * Redis, which `webServer` cannot provision): + * docker compose -f ../deploy/docker/docker-compose.yml up -d + * npm run dev # or the built `server`/`web`, whichever is already running + * + * Run with: npx playwright test e2e/mfa.spec.ts + */ +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 1, + reporter: "list", + use: { + baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000", + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/web/tsconfig.json b/web/tsconfig.json index 9e9bbf7..e7f0015 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -36,6 +36,8 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "e2e", + "playwright.config.ts" ] }