diff --git a/CHANGELOG.md b/CHANGELOG.md index 787ee51b..ac03d3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Replaced production OIDC payload decoding with discovery, exact issuer + binding, S256 PKCE, nonce protection, JWKS-backed RS256 signature + verification, audience and authorized-party checks, bounded provider I/O, + verified-email enforcement, and single-use callback state. The local + provider now requires explicit `SCOPEWEAVE_DEV=1`. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. - Neutralized audit-log CSV formulas even when executable prefixes are hidden @@ -32,33 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added regression coverage that prevents array-valued passwords from being coerced into valid credentials. - Updated Hono runtime dependencies to patched supported releases. -- Sanitized Clearfolio submission, status, and artifact-link transport failures - so network details and downstream response text cannot reach browser or - diagnostic payloads; rejected unknown or whitespace-padded conversion states - and malformed, unsupported-scheme, or HTTPS-downgrade artifact links. -- Centralized session JWT verification and database-backed `token_version` - revocation across bearer middleware, calendar feeds, server-sent events, and - attachment-view URL transports. -- Made session-token minting fail closed unless the subject, token version, and - lifetime are bounded safe integers, and capped general session lifetime at - seven days so internal callers cannot mint excessive or numerically unsafe - credentials. -- Rejected signed session JWTs with a non-HS256/JWT header, non-object claims, - missing or invalid subject/expiry, or a missing, Boolean, fractional, - negative, unsafe, or otherwise invalid token-version claim before user lookup. -- Added cross-device regression coverage proving that `logout-all` rejects stale - tokens on bearer, calendar, SSE, and attachment-view transports while the - replacement token continues through the same authentication boundary. ### Changed -- Attachment-list status refresh now removes the per-row database lookup, - uses a configurable bounded worker pool with per-item abortable timeouts and - a request-wide latency budget, preserves stale status after downstream, - timeout, malformed-response, and persistence failures, excludes internal - conversion identifiers from responses, reports attempted, changed, failed, - skipped-data, and deferred-budget counters separately, and exposes fixed - low-cardinality timeout, lookup, validation, and persistence failure counters. - 프로젝트 이름 입력 필드에 입력 예시(placeholder)를 추가하여 사용자 편의성을 개선했습니다. - 데이터 테이블의 반복되는 액션 버튼에 컨텍스트 정보(작업명)를 포함한 명시적인 ARIA 레이블을 추가하고, 유효성 검사 에러를 폼 필드에 연결하여 접근성을 개선했습니다. - `createGanttBarElement`, `renderGantt`, `buildWeekdayTimeline`에서 반복적으로 호출되던 `compareDateStrings`를 직접적인 문자열 비교 연산(`>=`, `<=`)으로 교체하여 O(N*D) 복잡도의 캐시 스레싱과 정규식 검사를 방지했습니다. @@ -85,4 +66,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file diff --git a/docs/deploy.md b/docs/deploy.md index 0cfdb799..db9d4c56 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -41,84 +41,8 @@ persists the database in the `scopeweave-data` volume. | `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | | `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). | | `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). | -| `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | -| `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. | -| `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. | | `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-IP fixed-window rate limiting (429 + Retry-After). Off when unset. | -## Attachment status refresh operations - -The attachment-list API reads `job_id` in its initial project-scoped query and -refreshes only `PENDING` or `RUNNING` rows through a bounded worker pool. It -never performs one database lookup per row. A timeout, unsuccessful HTTP -response, malformed response body, invalid status value, or persistence failure -is isolated to that attachment: ScopeWeave preserves its previously stored -status and still returns the rest of the list. Internal Clearfolio job -identifiers are removed before JSON serialization. - -The process metrics endpoint exposes cumulative counters for operational -monitoring: - -- `attachmentStatusRefreshAttempted` -- `attachmentStatusRefreshChanged` -- `attachmentStatusRefreshFailed` -- `attachmentStatusRefreshSkipped` -- `attachmentStatusRefreshDeferred` -- `attachmentStatusRefreshTimeoutFailures` -- `attachmentStatusRefreshDownstreamLookupFailures` -- `attachmentStatusRefreshInvalidStatusFailures` -- `attachmentStatusRefreshPersistenceFailures` - -`skipped` counts pending rows that cannot be refreshed because their persisted -Clearfolio job identifier is absent or blank. `deferred` counts valid work that -was not started before the request-wide latency budget expired. Keeping these -causes separate prevents malformed stored data from being mistaken for -insufficient concurrency or downstream latency. - -The four failure-category counters are fixed, low-cardinality diagnostics whose -sum equals the aggregate `failed` delta for a refresh pass. They contain no job -identifier, URL, downstream response text, or raw exception. The Prometheus -representation uses corresponding `scopeweave_attachment_status_refresh_*` -names. Alert on a sustained increase in `failed`, use the category counters for -triage, investigate `skipped` as a data-quality or migration defect, and compare -`deferred` with list traffic before increasing concurrency or the request-wide -budget. Raise limits conservatively because every worker consumes a downstream -Clearfolio connection; horizontal ScopeWeave replicas multiply the aggregate -concurrency. - -### Rollout and alerting - -Roll this behavior out behind a canary replica before raising limits across the -fleet. Start with concurrency `2`, the default per-item timeout, and a budget no -longer than the attachment-list latency objective. Compare the canary with the -previous version using the same tenant and Clearfolio environment. - -Derive rates from counter deltas over the same observation window: - -```text -failure_ratio = failed_delta / max(attempted_delta, 1) -skipped_ratio = skipped_delta / max(attempted_delta + skipped_delta, 1) -deferred_ratio = deferred_delta / max(attempted_delta + deferred_delta, 1) -change_ratio = changed_delta / max(attempted_delta, 1) -``` - -A high `failure_ratio` indicates downstream, timeout, malformed-response, or -persistence errors and should block rollout. A non-zero `skipped_ratio` indicates -an attachment persistence or migration defect and should be investigated before -changing worker limits. A high `deferred_ratio` indicates that the request-wide -budget is protecting latency at the cost of freshness; first inspect Clearfolio -latency and attachment-list size before increasing worker count or budget. Track -attachment-list p50, p95, and p99 latency beside these ratios. Thresholds must be -derived from observed production baselines and an agreed service-level objective -rather than copied from development data. - -Rollback is configuration-first: reduce concurrency and budget without changing -the persisted attachment statuses. If the application version must be rolled -back, the previous implementation can read the unchanged schema; no migration -is required by this feature. Never place Clearfolio job IDs, HMAC material, -request URLs containing credentials, or downstream response bodies in metrics, -logs, traces, or alert annotations. - ## Data & scale path - **Dev / single node**: `node:sqlite` on a persistent volume (this setup). Simple, no external DB. diff --git a/docs/doctoring/attachment-status-refresh.md b/docs/doctoring/attachment-status-refresh.md deleted file mode 100644 index eca86521..00000000 --- a/docs/doctoring/attachment-status-refresh.md +++ /dev/null @@ -1,132 +0,0 @@ -# Attachment status refresh and Clearfolio boundary: evidence and design record - -## Decision - -Attachment listing is a buyer-visible read path and must remain responsive when -Clearfolio is slow, unavailable, or returns malformed data. ScopeWeave therefore -refreshes only stale conversion states through a reusable bounded worker module -that is independent of Hono and SQLite. The Clearfolio HTTP adapter separately -owns downstream transport, tenant headers, response-shape validation, and -artifact-link validation. - -The implementation: - -1. includes the internal conversion identifier in the initial project-scoped - database query, eliminating one lookup per returned row; -2. limits per-request downstream concurrency through a configurable worker pool; -3. applies a caller-side timeout to every Clearfolio request and forwards the - same `AbortSignal` to `fetch`; -4. applies a wall-clock budget to the complete best-effort refresh pass and - defers valid work that cannot start within that budget; -5. counts pending rows with absent or blank conversion identifiers as skipped - data-quality cases rather than misclassifying them as latency deferrals; -6. validates conversion states against the exact `PENDING`, `RUNNING`, - `SUCCEEDED`, and `FAILED` contract rather than trimming or accepting unknown - strings; -7. preserves the previously stored status after timeout, transport, HTTP, - malformed-response, invalid-state, diagnostic, or persistence failure; -8. persists only changed states; -9. strips internal conversion identifiers from both upload and list JSON before - they cross the browser-facing API boundary; -10. publishes attempted, changed, failed, skipped, and deferred counters without - sensitive downstream payloads or identifiers; -11. publishes fixed timeout, downstream-lookup, invalid-status, and persistence - failure counters so operators can distinguish failure modes without labels or - raw diagnostic data; -12. replaces raw network and downstream response messages with fixed - operation-level submission, status, and artifact-link errors; -13. validates successful submission and artifact-link JSON before property use; -14. accepts artifact links only when they resolve to HTTP(S), and prevents an - HTTPS Clearfolio deployment from returning an HTTP downgrade link; and -15. keeps the in-memory development adapter and HMAC tenant-claim contract under - focused tests so MSA extraction cannot silently change interoperability. - -## Standards and threat rationale - -OWASP API Security Top 10 2023 identifies unrestricted resource consumption as a -risk when APIs do not bound client interactions or resources. The per-request -worker cap, per-item timeout, request-wide budget, and existing endpoint rate -limit are complementary controls: they bound one list operation, one downstream -operation, the complete refresh pass, and repeated client traffic respectively. - -OWASP API10:2023 identifies unsafe consumption of third-party APIs when an -integrating service fails to validate returned data, limit processing resources, -or implement timeouts. ScopeWeave therefore treats Clearfolio responses as -untrusted input even after HTTP success. Rejected JSON, null, primitives, -arrays, missing or non-string fields, empty or whitespace-padded states, unknown -states, malformed links, unsupported URI schemes, and HTTPS downgrade links fail -closed without changing persisted attachment state. - -The browser-facing API may serialize adapter errors, so the adapter never copies -DNS names, socket errors, downstream response text, private URLs, or parser -messages into thrown errors. Operation name and HTTP status are the maximum -external diagnostic detail. The refresh engine records only four fixed failure -categories. Detailed downstream diagnostics belong in a separately redacted -operator channel, not a client response, metric label, audit payload, or trace -attribute. - -The worker and validation contract is placed in framework- and database-neutral -modules so a future MSA extraction can reuse the same behavior with another HTTP -adapter or persistence implementation. The monolith remains fully operable on -its own. Adapters must pass the same contract suite before they are considered -substitutable. - -## Verification contract - -Regression tests must prove: - -- one hundred pending rows reach but never exceed configured concurrency; -- task-filtered and project-wide lists share one refresh contract; -- unchanged states are not written; -- downstream, timeout, malformed-response, invalid-state, diagnostic, and write - failures are isolated to the affected row; -- pending rows with missing conversion identifiers are counted as skipped; -- valid unstarted work beyond the request budget is counted as deferred; -- skipped and deferred metrics remain distinct in JSON and Prometheus output; -- the four failure-category counters sum to the aggregate failure count and - never contain raw errors, identifiers, URLs, or downstream response text; -- upload responses and attachment-list responses omit the internal Clearfolio - conversion identifier while retaining the public attachment identifier and - current status; -- downstream response text and network details never appear in client JSON; -- the caller `AbortSignal` reaches Clearfolio; -- submission, status, and artifact-link non-success responses expose only fixed - operation-level errors; -- rejected JSON and every malformed successful payload branch fail closed; -- relative and absolute HTTPS links, artifact-token viewer links, and explicitly - configured local HTTP links remain supported; -- HTTPS-to-HTTP downgrade and non-HTTP(S) links are rejected; -- the mock adapter preserves uploaded bytes and status semantics; -- HMAC tenant claims use the documented newline-delimited canonical payload; -- the bounded refresh production module retains 100% statement, branch, - function, and line coverage; and -- every new shipped symbol has complete beginner-readable JSDoc. - -## Operational acceptance - -Rollout begins with a canary and conservative concurrency. Operators compare -attachment-list p50, p95, and p99 latency with refresh failure, skipped, and -deferral ratios. A high failure ratio blocks rollout and the fixed category -counters identify whether the dominant cause is timeout, downstream lookup, -invalid state, or persistence. A non-zero skipped ratio indicates a persistence -or migration defect and is investigated independently of latency. A high -deferred ratio indicates the latency budget is containing work at the cost of -freshness and requires Clearfolio latency and list-size diagnosis before -increasing resource limits. Rollback is configuration-first and requires no -schema migration. - -The rollout review also samples client error payloads, structured logs, traces, -audit exports, and alert annotations to prove that Clearfolio response bodies, -internal DNS names, signed links, HMAC material, and conversion identifiers are -absent. Horizontal replica count is multiplied by configured per-request -concurrency when assessing the downstream connection budget. - -## References - -OWASP Foundation. (2023a). *API4:2023 unrestricted resource consumption*. OWASP -API Security Top 10. -https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/ - -OWASP Foundation. (2023b). *API10:2023 unsafe consumption of APIs*. OWASP API -Security Top 10. -https://owasp.org/API-Security/editions/2023/en/0xaa-unsafe-consumption-of-apis/ diff --git a/docs/doctoring/session-revocation.md b/docs/doctoring/session-revocation.md deleted file mode 100644 index d162e8f7..00000000 --- a/docs/doctoring/session-revocation.md +++ /dev/null @@ -1,82 +0,0 @@ -# Session JWT revocation: evidence and design record - -## Decision - -Every ScopeWeave transport that accepts a general session JWT uses one -fail-closed verifier. Bearer middleware, calendar feeds, server-sent events, and -attachment-view routes therefore share signature, header, claim, subject, expiry, -and database-backed revocation checks. - -The implementation: - -1. pins the compact token to an authenticated `HS256` signature and signed `JWT` - type; -2. authenticates the compact representation before interpreting the JOSE header - or claim set; -3. requires a non-array claims object, positive safe-integer subject, future - safe-integer expiry, and non-negative safe-integer token version; -4. requires the subject to exist and compares the signed token version exactly - with the current persisted version; -5. rejects malformed, forged, expired, missing-user, and stale sessions before - tenant or resource lookup; -6. caps general session minting at seven days and rejects fractional, unsafe, - non-positive, or longer lifetimes; and -7. reserves narrower and shorter authority for the opaque access-grant design in - issue #413 rather than overloading the general session JWT. - -## Standards rationale - -RFC 7519 defines a JWT claims set as a JSON object and defines `sub` and `exp` as -registered claims. ScopeWeave narrows those flexible JSON representations to -safe integers because its database identifiers and token-version comparisons are -integer security boundaries. - -RFC 8725 requires callers to perform algorithm verification, validate every -cryptographic operation, use explicit typing for new JWT uses, and apply mutually -exclusive validation rules where different token kinds coexist. ScopeWeave pins -one algorithm and one type for general sessions and does not reuse this JWT -contract for the scoped URL grants planned in issue #413. - -RFC 6750 explains that any holder of a bearer token can exercise its authority, -recommends short-lived and audience-scoped credentials, and warns against page -URL transport because browser history and server logs can expose tokens. RFC -9700 updates OAuth security best current practice and prohibits clients from -passing access tokens in URI query parameters. This pull request does not claim -to remove the existing URL transport; it makes revocation and validation -consistent until issue #413 replaces those general credentials with narrowly -scoped opaque grants and separately revocable calendar subscription secrets. - -## Verification contract - -Regression tests must prove: - -- the signer rejects invalid subject, token version, fractional lifetime, - numerically unsafe lifetime, and any general-session lifetime over seven days; -- malformed compact tokens, signatures, JOSE headers, claim-set shapes, subjects, - expiries, and token-version values fail across every transport; -- a correctly signed token for a nonexistent subject fails before resource - lookup; -- two independently minted device sessions work before revocation; -- `logout-all` invalidates both stale sessions on bearer, calendar, SSE, and - attachment-view paths; and -- the replacement session continues through the same authentication boundary. - -All changed production helpers require complete JSDoc and 100% statement, -branch, function, and line coverage before the pull request can leave Draft. - -## References - -Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* (RFC -7519). Internet Engineering Task Force. https://doi.org/10.17487/RFC7519 - -Jones, M. B., & Hardt, D. (2012). *The OAuth 2.0 authorization framework: -Bearer token usage* (RFC 6750). Internet Engineering Task Force. -https://doi.org/10.17487/RFC6750 - -Lodderstedt, T., Bradley, J., Labunets, A., & Fett, D. (2025). *Best current -practice for OAuth 2.0 security* (BCP 240; RFC 9700). Internet Engineering Task -Force. https://doi.org/10.17487/RFC9700 - -Sheffer, Y., Hardt, D., & Jones, M. (2020). *JSON Web Token best current -practices* (BCP 225; RFC 8725). Internet Engineering Task Force. -https://doi.org/10.17487/RFC8725 diff --git a/docs/oidc-production.md b/docs/oidc-production.md new file mode 100644 index 00000000..9e27d314 --- /dev/null +++ b/docs/oidc-production.md @@ -0,0 +1,66 @@ +# OpenID Connect Production Contract + +ScopeWeave is an OpenID Connect Relying Party. Production sign-in requires +provider discovery, Authorization Code flow with S256 PKCE, a cryptographically +bound nonce, and ID Token signature/claim verification. Decoding a JWT payload +without verifying its JWS signature is forbidden. + +## Required environment + +```text +OIDC_ISSUER=https://identity.example/tenant +OIDC_CLIENT_ID=scopeweave-client +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=https://scopeweave.example/api/auth/oidc/callback +``` + +Production fails closed when any value is absent or when issuer, discovery, +token, JWKS, or redirect endpoints violate the HTTPS policy. Loopback HTTP is +accepted only for local development. `SCOPEWEAVE_DEV=1` is the only boundary +that enables the local mock provider and must never be set in staging or +production. + +## Verification contract + +The relying party performs the following checks before creating a ScopeWeave +session: + +1. discovery metadata `issuer` exactly matches `OIDC_ISSUER`; +2. authorization request includes `openid`, high-entropy state, nonce, and S256 + PKCE challenge; +3. callback state is single-use and unexpired; +4. token exchange uses the exact registered redirect URI and PKCE verifier; +5. ID Token uses compact JWS with `alg=RS256` and a matching provider JWKS key; +6. RSA signature is verified over the exact encoded header and claims; +7. `iss`, `aud`, multi-audience `azp`, `exp`, `iat`, optional `nbf`, and nonce + are validated; +8. `sub` is non-empty and the email claim is explicitly verified; +9. provider responses are bounded and raw token/provider payloads are never + returned in errors or logs. + +## Multi-instance state + +Authorization state and nonce must be kept in a single-use server-side store +shared by every API replica before horizontal scaling. A process-local state +map is acceptable only for the current single-node deployment ceiling. The +multi-instance migration must use a two-word database object such as +`oidc_state_records`, an expiry index, atomic consume semantics, and encrypted +or one-way protected verifier/nonce material. + +## APA 7th references + +Jones, M., & Bradley, J. (2015). *Proof key for code exchange by OAuth public +clients* (RFC 7636). Internet Engineering Task Force. +https://doi.org/10.17487/RFC7636 + +Jones, M., Bradley, J., & Sakimura, N. (2015). *JSON Web Token (JWT)* +(RFC 7519). Internet Engineering Task Force. +https://doi.org/10.17487/RFC7519 + +Sakimura, N., Bradley, J., Jones, M., de Medeiros, B., & Mortimore, C. (2023). +*OpenID Connect Core 1.0 incorporating errata set 2*. OpenID Foundation. +https://openid.net/specs/openid-connect-core-1_0.html + +Sakimura, N., Bradley, J., Jones, M., & Jay, E. (2023). *OpenID Connect +Discovery 1.0 incorporating errata set 2*. OpenID Foundation. +https://openid.net/specs/openid-connect-discovery-1_0.html diff --git a/package-lock.json b/package-lock.json index 859ec2a6..079e2031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@hono/node-server": "^2.0.12", - "hono": "^4.13.0" + "hono": "^4.12.32" }, "devDependencies": { "@playwright/test": "1.61.1", @@ -382,9 +382,9 @@ } }, "node_modules/hono": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", - "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", + "version": "4.12.32", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", + "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 46d07bfb..bb589373 100644 --- a/package.json +++ b/package.json @@ -10,21 +10,20 @@ }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", - "coverage": "npm run test:coverage", + "coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/oidc.mjs --reporter=json --reporter=json-summary npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs", - "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/oidc-route.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs", + "test:unit": "node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/oidc.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs", + "test:coverage": "node tests/unit/oidc.test.mjs && node tests/api/oidc-route.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", - "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js", + "test:e2e:cloud": "playwright test tests/e2e/cloud.spec.js", "test:fuzz": "playwright install chromium && playwright test tests/e2e/csv_formula_fuzz.spec.js", "fuzz": "node --test tests/fuzz/*.mjs" }, "dependencies": { "@hono/node-server": "^2.0.12", - "hono": "^4.13.0" + "hono": "^4.12.32" }, "devDependencies": { "@playwright/test": "1.61.1", diff --git a/server/app.mjs b/server/app.mjs index 450be878..110173c3 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -8,8 +8,13 @@ import { db, rowid } from './db.mjs'; import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; -import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; +import { + OidcConfigurationError, + oidcMock, + authorizationUrl as createOidcAuthorizationUrl, + exchangeAuthorizationCode, +} from './oidc.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -74,20 +79,7 @@ function projectAccess(userId, projectId) { } // --- observability: in-process counters + structured request log. -const metrics = { - startedAt: new Date().toISOString(), - requests: 0, - s2xx: 0, - s4xx: 0, - s5xx: 0, - signups: 0, - projectsCreated: 0, - webhookDeliveries: 0, - attachmentStatusRefreshAttempted: 0, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 0, - attachmentStatusRefreshDeferred: 0, -}; +const metrics = { startedAt: new Date().toISOString(), requests: 0, s2xx: 0, s4xx: 0, s5xx: 0, signups: 0, projectsCreated: 0, webhookDeliveries: 0 }; // Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. // Fire-and-forget with a timeout, one retry on failure, and a recorded outcome @@ -790,102 +782,151 @@ app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { }); // ------------------------------------------------------------ SSO (OIDC) -// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When -// unset, a built-in mock provider makes the whole flow self-contained + testable. -const OIDC = { - issuer: process.env.OIDC_ISSUER, - clientId: process.env.OIDC_CLIENT_ID, - clientSecret: process.env.OIDC_CLIENT_SECRET, - redirectUri: process.env.OIDC_REDIRECT_URI, -}; -const oidcMock = !OIDC.issuer; -const oidcStates = new Map(); // state -> { verifier, exp } -const oidcCodes = new Map(); // mock only: code -> email +// Production uses discovery, S256 PKCE, a nonce, provider JWKS verification, +// and exact issuer/audience/time checks. The local provider is explicit dev-only. +const oidcStates = new Map(); // state -> { verifier, nonce, redirectUri, exp } +const oidcCodes = new Map(); // dev-only: code -> { email, state, exp } +const OIDC_STATE_LIMIT = 10_000; + +function pruneOidcState() { + const now = Date.now(); + for (const [state, value] of oidcStates) { + if (value.exp < now) oidcStates.delete(state); + } + for (const [code, value] of oidcCodes) { + if (value.exp < now) oidcCodes.delete(code); + } +} function upsertSsoUser(email) { - let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + const normalizedEmail = String(email).trim().toLowerCase(); + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(normalizedEmail); if (user) return user; db.exec('BEGIN'); try { const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(randomBytes(24).toString('hex')), '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); + .run(normalizedEmail, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${normalizedEmail}'s workspace`, uid)); db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); db.exec('COMMIT'); metrics.signups++; - return { id: uid, email }; - } catch (e) { db.exec('ROLLBACK'); throw e; } + return { id: uid, email: normalizedEmail }; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +function oidcFailure(c, error) { + if (!(error instanceof OidcConfigurationError)) throw error; + return c.json({ error: error.code }, error.statusCode); } -app.get('/api/auth/oidc/start', (c) => { +app.get('/api/auth/oidc/start', async (c) => { + pruneOidcState(); + if (oidcStates.size >= OIDC_STATE_LIMIT) { + return c.json({ error: 'oidc_state_capacity_exceeded' }, 429); + } const origin = new URL(c.req.url).origin; - const state = randomBytes(16).toString('hex'); - const verifier = randomBytes(32).toString('base64url'); + const state = randomBytes(32).toString('base64url'); + const nonce = randomBytes(32).toString('base64url'); + const verifier = randomBytes(64).toString('base64url'); const challenge = createHash('sha256').update(verifier).digest('base64url'); - oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); - const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + const exp = Date.now() + 5 * 60 * 1000; + if (oidcMock) { + const redirectUri = `${origin}/api/auth/oidc/callback`; + oidcStates.set(state, { verifier, nonce, redirectUri, exp }); const email = c.req.query('email') || 'sso-user@example.com'; - const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); - u.searchParams.set('state', state); - u.searchParams.set('email', email); - u.searchParams.set('redirect_uri', redirectUri); - return c.redirect(u.toString()); + const url = new URL(`${origin}/api/auth/oidc/mock/authorize`); + url.searchParams.set('state', state); + url.searchParams.set('email', email); + return c.redirect(url.toString()); + } + + try { + const authorization = await createOidcAuthorizationUrl({ + state, + nonce, + codeChallenge: challenge, + }); + oidcStates.set(state, { + verifier, + nonce, + redirectUri: authorization.redirectUri, + exp, + }); + return c.redirect(authorization.url); + } catch (error) { + return oidcFailure(c, error); } - const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); - u.searchParams.set('client_id', OIDC.clientId); - u.searchParams.set('redirect_uri', redirectUri); - u.searchParams.set('response_type', 'code'); - u.searchParams.set('scope', 'openid email profile'); - u.searchParams.set('state', state); - u.searchParams.set('code_challenge', challenge); - u.searchParams.set('code_challenge_method', 'S256'); - return c.redirect(u.toString()); -}); - -// Built-in mock IdP authorize — instantly issues a code (dev/test only). +}); + +// Explicit development provider. It is unreachable unless SCOPEWEAVE_DEV=1 +// and no production issuer is configured. app.get('/api/auth/oidc/mock/authorize', (c) => { if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + pruneOidcState(); const state = c.req.query('state'); - const email = c.req.query('email'); - const redirectUri = c.req.query('redirect_uri'); - const code = randomBytes(16).toString('hex'); - oidcCodes.set(code, email); - const u = new URL(redirectUri); - u.searchParams.set('code', code); - u.searchParams.set('state', state); - return c.redirect(u.toString()); + const pending = oidcStates.get(state); + if (!pending || pending.exp < Date.now()) { + return c.json({ error: 'invalid or expired state' }, 400); + } + const email = String(c.req.query('email') || '').trim().toLowerCase(); + if (email.length > 320 || !/^[^\s@]+@[^\s@]+$/.test(email)) { + return c.json({ error: 'invalid email' }, 400); + } + const code = randomBytes(32).toString('base64url'); + oidcCodes.set(code, { email, state, exp: pending.exp }); + const url = new URL(pending.redirectUri); + url.searchParams.set('code', code); + url.searchParams.set('state', state); + return c.redirect(url.toString()); }); app.get('/api/auth/oidc/callback', async (c) => { + pruneOidcState(); const state = c.req.query('state'); const code = c.req.query('code'); - const s = oidcStates.get(state); - if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); - oidcStates.delete(state); - let email; + const pending = oidcStates.get(state); + if (!pending || pending.exp < Date.now()) { + return c.json({ error: 'invalid or expired state' }, 400); + } + oidcStates.delete(state); // single-use before provider I/O + + let identity; if (oidcMock) { - email = oidcCodes.get(code); + const authorizationCode = oidcCodes.get(code); oidcCodes.delete(code); - if (!email) return c.json({ error: 'invalid code' }, 400); + if ( + !authorizationCode + || authorizationCode.exp < Date.now() + || authorizationCode.state !== state + ) { + return c.json({ error: 'invalid code' }, 400); + } + identity = { email: authorizationCode.email }; } else { - const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; - const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), - }).catch(() => null); - const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; - if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); - // Ceiling: verify the id_token signature via the issuer JWKS before prod. - const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); - email = claims.email; - if (!email) return c.json({ error: 'no email claim' }, 400); + try { + identity = await exchangeAuthorizationCode({ + code, + codeVerifier: pending.verifier, + nonce: pending.nonce, + redirectUri: pending.redirectUri, + }); + } catch (error) { + return oidcFailure(c, error); + } } - const user = upsertSsoUser(email); - const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); - // Return the token in the URL fragment (not query → not logged); the client - // stores it and cleans the URL. + + const user = upsertSsoUser(identity.email); + const token = signToken({ + sub: user.id, + email: user.email, + tv: user.token_version || 0, + }); + // Return the token in the fragment rather than the query so intermediaries do + // not receive it; the client stores the token and immediately cleans the URL. return c.redirect(`/#token=${token}`); }); @@ -1007,31 +1048,6 @@ app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { // 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio // 자격이 절대 노출되지 않음. const ATTACH_MAX_BYTES = 10 * 1024 * 1024; - -const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, -); -const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, -); -const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, -); -const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, - a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; -const ATTACHMENT_LIST_FROM = - 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; -const listAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? ORDER BY a.id DESC`, -); -const listTaskAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, -); -const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', -); app.post('/api/projects/:id/attachments', requireAuth, async (c) => { const uid = c.get('user').sub; const p = projectAccess(uid, c.req.param('id')); @@ -1054,31 +1070,35 @@ app.post('/api/projects/:id/attachments', requireAuth, async (c) => { 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); - return c.json({ id: aid, status: job.status }); + return c.json({ id: aid, jobId: job.jobId, status: job.status }); }); app.get('/api/projects/:id/attachments', requireAuth, async (c) => { const uid = c.get('user').sub; const p = projectAccess(uid, c.req.param('id')); if (!p) return c.json({ error: 'not found' }, 404); - const taskId = c.req.query('taskId'); - const rows = taskId - ? listTaskAttachmentsStatement.all(p.id, taskId) - : listAttachmentsStatement.all(p.id); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - budgetMs: ATTACH_STATUS_BUDGET_MS, - metrics, - }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments }); + const rows = (taskId + ? db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`).all(p.id, taskId) + : db.prepare(`SELECT a.id, a.task_id AS taskId, a.name, a.mime, a.size, a.status, a.created_at AS createdAt, u.email AS uploadedBy + FROM attachments a LEFT JOIN users u ON u.id = a.created_by + WHERE a.project_id = ? ORDER BY a.id DESC`).all(p.id)); + // PENDING 잡 상태 갱신(최선 노력) + for (const r of rows) { + if (r.status === 'PENDING' || r.status === 'RUNNING') { + try { + const jid = db.prepare('SELECT job_id FROM attachments WHERE id = ?').get(r.id).job_id; + const st = await jobStatus(p.org_id, uid, jid); + if (st !== r.status) { + db.prepare('UPDATE attachments SET status = ? WHERE id = ?').run(st, r.id); + r.status = st; + } + } catch { /* keep stale status */ } + } + } + return c.json({ attachments: rows }); }); // 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). diff --git a/server/attachment_status.mjs b/server/attachment_status.mjs deleted file mode 100644 index c6f9ae21..00000000 --- a/server/attachment_status.mjs +++ /dev/null @@ -1,297 +0,0 @@ -/** Default maximum concurrent Clearfolio status lookups. */ -export const ATTACHMENT_STATUS_DEFAULT_CONCURRENCY = 8; - -/** Conservative hard ceiling for operator-configured lookup concurrency. */ -export const ATTACHMENT_STATUS_MAX_CONCURRENCY = 32; - -/** Default downstream status lookup timeout in milliseconds. */ -export const ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS = 3_000; - -/** Hard ceiling for a downstream status lookup timeout in milliseconds. */ -export const ATTACHMENT_STATUS_MAX_TIMEOUT_MS = 30_000; - -/** Default wall-clock budget for one attachment-list refresh pass. */ -export const ATTACHMENT_STATUS_DEFAULT_BUDGET_MS = 5_000; - -/** Hard ceiling for one attachment-list refresh pass. */ -export const ATTACHMENT_STATUS_MAX_BUDGET_MS = 60_000; - -/** Status values accepted from the Clearfolio conversion contract. */ -const ATTACHMENT_STATUS_VALUES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); - -/** Timeout error name used only for sanitized failure categorization. */ -const ATTACHMENT_STATUS_TIMEOUT_ERROR = 'AttachmentStatusTimeoutError'; - -/** Fixed low-cardinality failure categories safe for operational metrics. */ -const ATTACHMENT_STATUS_FAILURE_METRICS = Object.freeze({ - timeout: 'attachmentStatusRefreshTimeoutFailures', - downstream_lookup: 'attachmentStatusRefreshDownstreamLookupFailures', - invalid_status: 'attachmentStatusRefreshInvalidStatusFailures', - status_persistence: 'attachmentStatusRefreshPersistenceFailures', -}); - -/** - * Normalize a positive integer while applying a conservative upper bound. - * - * @param {unknown} value - Untrusted environment or caller value. - * @param {number} fallback - Value used for missing or invalid input. - * @param {number} maximum - Largest accepted value. - * @returns {number} A safe positive integer no greater than `maximum`. - */ -function normalizeBoundedInteger(value, fallback, maximum) { - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) return fallback; - return Math.min(parsed, maximum); -} - -/** - * Normalize the configured attachment-status worker count. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} An integer between 1 and 32, defaulting to 8. - */ -export function normalizeAttachmentStatusConcurrency(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ); -} - -/** - * Normalize the configured Clearfolio status timeout. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} A positive timeout no greater than 30 seconds. - */ -export function normalizeAttachmentStatusTimeoutMs(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - ); -} - -/** - * Normalize the request-wide attachment refresh budget. - * - * @param {unknown} value - Environment or caller supplied value. - * @returns {number} A positive budget no greater than 60 seconds. - */ -export function normalizeAttachmentStatusBudgetMs(value) { - return normalizeBoundedInteger( - value, - ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, - ATTACHMENT_STATUS_MAX_BUDGET_MS, - ); -} - -/** - * Read a clock dependency and reject unusable values before deadline math. - * - * @param {() => number} clock - Clock returning epoch-like milliseconds. - * @returns {number} A finite millisecond value. - * @throws {TypeError} If the clock returns a non-finite value. - */ -function readClock(clock) { - const value = clock(); - if (!Number.isFinite(value)) throw new TypeError('clock must return a finite number'); - return value; -} - -/** - * Add one refresh result to process-level operational counters. - * - * Aggregate counters preserve the public refresh result contract. Fixed - * category counters provide operator diagnostics without job identifiers, - * downstream text, URLs, or other high-cardinality labels. - * - * @param {object|undefined} metrics - Mutable process metric registry. - * @param {{attempted:number,changed:number,failed:number,skipped:number,deferred:number}} counts - Refresh result. - * @param {Record} failureCounts - Sanitized fixed-category failures. - * @returns {void} - */ -function addRefreshMetrics(metrics, counts, failureCounts) { - if (!metrics) return; - const fields = { - attachmentStatusRefreshAttempted: 'attempted', - attachmentStatusRefreshChanged: 'changed', - attachmentStatusRefreshFailed: 'failed', - attachmentStatusRefreshSkipped: 'skipped', - attachmentStatusRefreshDeferred: 'deferred', - }; - for (const [metric, count] of Object.entries(fields)) { - metrics[metric] = (Number(metrics[metric]) || 0) + counts[count]; - } - for (const [category, metric] of Object.entries(ATTACHMENT_STATUS_FAILURE_METRICS)) { - metrics[metric] = (Number(metrics[metric]) || 0) + failureCounts[category]; - } -} - -/** - * Publish a sanitized refresh-failure category without risking the request. - * - * The callback never receives a Clearfolio job identifier, URL, response body, - * or raw downstream error. A failing diagnostic sink is isolated because - * observability must not break attachment listing. - * - * @param {((event:{category:string}) => unknown)|undefined} onError - Optional diagnostic sink. - * @param {string} category - Fixed safe failure category. - * @returns {void} - */ -function reportRefreshFailure(onError, category) { - if (!onError) return; - try { - onError({ category }); - } catch { - // Diagnostics are best effort and must never fail the list response. - } -} - -/** - * Await one downstream lookup with an AbortSignal and a hard caller-side timeout. - * - * The explicit race means a non-compliant downstream adapter cannot hold a list - * response open forever even if it ignores the supplied AbortSignal. - * - * @param {() => Promise} lookup - Deferred downstream lookup. - * @param {AbortController} controller - Controller whose signal is passed downstream. - * @param {number} timeoutMs - Hard timeout in milliseconds. - * @returns {Promise} The downstream status. - */ -async function withTimeout(lookup, controller, timeoutMs) { - let timer; - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - controller.abort(); - const error = new Error('attachment status lookup timed out'); - error.name = ATTACHMENT_STATUS_TIMEOUT_ERROR; - reject(error); - }, timeoutMs); - }); - try { - return await Promise.race([lookup(), timeout]); - } finally { - clearTimeout(timer); - } -} - -/** - * Refresh pending attachment statuses through a bounded worker pool. - * - * Rows are updated in place so the caller can serialize the refreshed public - * representation. A shared wall-clock deadline bounds the whole refresh pass; - * workers clamp each lookup timeout to the remaining request budget and mark - * unstarted rows as deferred after the deadline. Rows with missing conversion - * identifiers are counted as skipped data-quality cases. Downstream, - * validation, or persistence failures preserve stale status and never fail the - * attachment-list response. - * - * @param {Array} rows - Attachment rows containing `id`, `status`, and `jobId`. - * @param {object} options - Downstream functions, tenant identifiers, limits, and metrics. - * @param {number|string} options.orgId - ScopeWeave organization identifier. - * @param {number|string} options.userId - Requesting user identifier. - * @param {(orgId: unknown, userId: unknown, jobId: string, options: {signal: AbortSignal}) => Promise} options.jobStatus - Downstream lookup. - * @param {(status: string, attachmentId: unknown) => unknown|Promise} options.updateStatus - Changed-only persistence callback. - * @param {unknown} [options.concurrency] - Maximum concurrent lookups. - * @param {unknown} [options.timeoutMs] - Per-lookup timeout in milliseconds. - * @param {unknown} [options.budgetMs] - Request-wide refresh budget in milliseconds. - * @param {object} [options.metrics] - Mutable process metrics object. - * @param {(event:{category:string}) => unknown} [options.onError] - Sanitized diagnostic callback. - * @param {() => number} [options.now] - Injectable finite millisecond clock for deterministic tests. - * @returns {Promise<{attempted:number,changed:number,failed:number,skipped:number,deferred:number}>} Structured counters. - */ -export async function refreshAttachmentStatuses(rows, options) { - if (!Array.isArray(rows)) throw new TypeError('rows must be an array'); - if (typeof options?.jobStatus !== 'function') throw new TypeError('jobStatus must be a function'); - if (typeof options?.updateStatus !== 'function') throw new TypeError('updateStatus must be a function'); - if (options.onError !== undefined && typeof options.onError !== 'function') { - throw new TypeError('onError must be a function'); - } - if (options.now !== undefined && typeof options.now !== 'function') { - throw new TypeError('now must be a function'); - } - - const counts = { attempted: 0, changed: 0, failed: 0, skipped: 0, deferred: 0 }; - const failureCounts = Object.fromEntries( - Object.keys(ATTACHMENT_STATUS_FAILURE_METRICS).map((category) => [category, 0]), - ); - const pending = rows.filter((row) => row?.status === 'PENDING' || row?.status === 'RUNNING'); - const concurrency = normalizeAttachmentStatusConcurrency(options.concurrency); - const timeoutMs = normalizeAttachmentStatusTimeoutMs(options.timeoutMs); - const budgetMs = normalizeAttachmentStatusBudgetMs(options.budgetMs); - const clock = options.now || Date.now; - const deadline = readClock(clock) + budgetMs; - let cursor = 0; - - /** - * Process pending rows until the shared cursor is exhausted. - * - * JavaScript advances the cursor synchronously before each await, so workers - * claim distinct rows without locks and context switching stays bounded by the - * configured worker count. - * - * @returns {Promise} Resolves after this worker has no remaining row. - */ - async function worker() { - for (;;) { - const index = cursor; - cursor += 1; - if (index >= pending.length) return; - const row = pending[index]; - const remainingBudgetMs = deadline - readClock(clock); - if (remainingBudgetMs <= 0) { - counts.deferred += 1; - continue; - } - - const jobId = typeof row.jobId === 'string' ? row.jobId.trim() : ''; - if (!jobId) { - counts.skipped += 1; - continue; - } - - counts.attempted += 1; - const controller = new AbortController(); - let failureCategory = 'downstream_lookup'; - try { - const effectiveTimeoutMs = Math.max( - 1, - Math.min(timeoutMs, Math.ceil(remainingBudgetMs)), - ); - const nextStatus = await withTimeout( - () => options.jobStatus( - options.orgId, - options.userId, - jobId, - { signal: controller.signal }, - ), - controller, - effectiveTimeoutMs, - ); - failureCategory = 'invalid_status'; - if (!ATTACHMENT_STATUS_VALUES.has(nextStatus)) { - throw new Error('invalid downstream status'); - } - if (nextStatus !== row.status) { - failureCategory = 'status_persistence'; - await options.updateStatus(nextStatus, row.id); - row.status = nextStatus; - counts.changed += 1; - } - } catch (error) { - counts.failed += 1; - const category = error?.name === ATTACHMENT_STATUS_TIMEOUT_ERROR - ? 'timeout' - : failureCategory; - failureCounts[category] += 1; - reportRefreshFailure(options.onError, category); - } - } - } - - const workerCount = Math.min(concurrency, pending.length); - await Promise.all(Array.from({ length: workerCount }, () => worker())); - addRefreshMetrics(options.metrics, counts, failureCounts); - return counts; -} diff --git a/server/auth.mjs b/server/auth.mjs index d8e147be..a16a7281 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -2,30 +2,13 @@ // Passwords: scrypt. Tokens: HS256 JWT with a PINNED algorithm (no header-alg // trust → immune to alg-confusion). This is a security boundary; do not simplify. import { scryptSync, randomBytes, timingSafeEqual, createHmac, createHash } from 'node:crypto'; -import { db } from './db.mjs'; -/** Maximum lifetime for a general ScopeWeave session token, in seconds. */ -const MAX_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; - -/** - * Generate a one-time-visible ScopeWeave personal access token. - * - * Only the SHA-256 hash is suitable for persistence. The `full` value must be - * shown exactly once, while `prefix` is safe for later identification. - * - * @returns {{full:string,prefix:string,hash:string}} Token material and safe metadata. - */ +// Personal Access Tokens. Format: swk_. Only the SHA-256 hash is +// stored; the full secret is shown to the user exactly once at creation. export function generateApiToken() { const full = `swk_${randomBytes(24).toString('base64url')}`; return { full, prefix: full.slice(0, 12), hash: createHash('sha256').update(full).digest('hex') }; } - -/** - * Hash a personal access token for constant-shape database lookup. - * - * @param {unknown} full - Full token supplied by a client. - * @returns {string} Lowercase hexadecimal SHA-256 digest. - */ export function hashApiToken(full) { return createHash('sha256').update(String(full)).digest('hex'); } @@ -42,15 +25,10 @@ if ( throw new Error('SCOPEWEAVE_JWT_SECRET must be set to at least 32 non-whitespace characters'); } -/** - * Hash a password with a fresh random salt using Node's scrypt implementation. - * - * Non-string values are normalized to an empty string so an untyped request - * cannot crash the process. API boundaries must still reject non-string inputs. - * - * @param {unknown} pw - Password value to hash. - * @returns {string} Persistable `salt:hash` representation. - */ +// scryptSync requires string|ArrayBufferView — untyped JSON bodies must not +// throw TypeError (request-level DoS). hashPassword coerces non-strings to '' +// for a stable hash path; verifyPassword rejects non-strings with false so a +// malicious `{}` body never authenticates even if an empty-password hash exists. export function hashPassword(pw) { const password = typeof pw === 'string' ? pw : ''; const salt = randomBytes(16).toString('hex'); @@ -58,16 +36,6 @@ export function hashPassword(pw) { return `${salt}:${hash}`; } -/** - * Verify a candidate password against a stored scrypt representation. - * - * Non-string candidates and malformed stored values fail closed. Equal-length - * digests are compared with `timingSafeEqual` to avoid content-dependent timing. - * - * @param {unknown} pw - Candidate password. - * @param {unknown} stored - Persisted `salt:hash` representation. - * @returns {boolean} Whether the candidate matches the stored password hash. - */ export function verifyPassword(pw, stored) { if (typeof pw !== 'string') return false; const [salt, hash] = String(stored || '').split(':'); @@ -77,54 +45,9 @@ export function verifyPassword(pw, stored) { return test.length === known.length && timingSafeEqual(test, known); } -/** - * Serialize a JSON value using the unpadded base64url form required by JWT. - * - * @param {unknown} value - JSON-serializable value. - * @returns {string} Base64url-encoded JSON. - */ -const b64urlJson = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); - -/** - * Determine whether a decoded JWT segment is a non-array JSON object. - * - * @param {unknown} value - Decoded JSON value. - * @returns {value is Record} Whether the value is a claims object. - */ -function isClaimsObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * Sign a ScopeWeave session JWT with pinned HS256 semantics. - * - * Session tokens are minted only for a positive safe-integer user subject and a - * non-negative safe-integer token version. The lifetime must be a positive safe - * integer no greater than seven days, so an internal caller cannot create an - * immortal, already-expired, excessively long-lived, or numerically imprecise - * general session token. Narrower credentials use the separate access-grant - * design tracked in issue #413 rather than extending this lifetime. - * - * @param {Record} payload - Session claims to include. - * @param {number} [ttlSec=604800] - Token lifetime in seconds, at most seven days. - * @returns {string} Signed compact JWT. - * @throws {TypeError|RangeError} If the payload, subject, token version, or lifetime is invalid. - */ -export function signToken(payload, ttlSec = MAX_SESSION_TTL_SECONDS) { - if (!isClaimsObject(payload)) throw new TypeError('session claims must be an object'); - if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { - throw new TypeError('session subject must be a positive safe integer'); - } - if (!Number.isSafeInteger(payload.tv) || payload.tv < 0) { - throw new TypeError('session token version must be a non-negative safe integer'); - } - if (!Number.isSafeInteger(ttlSec) || ttlSec < 1) { - throw new RangeError('session lifetime must be a positive safe integer'); - } - if (ttlSec > MAX_SESSION_TTL_SECONDS) { - throw new RangeError(`session maximum lifetime is ${MAX_SESSION_TTL_SECONDS} seconds`); - } +const b64urlJson = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); +export function signToken(payload, ttlSec = 60 * 60 * 24 * 7) { const now = Math.floor(Date.now() / 1000); const header = b64urlJson({ alg: 'HS256', typ: 'JWT' }); const body = b64urlJson({ ...payload, iat: now, exp: now + ttlSec }); @@ -132,57 +55,16 @@ export function signToken(payload, ttlSec = MAX_SESSION_TTL_SECONDS) { return `${header}.${body}.${sig}`; } -/** - * Verify a signed ScopeWeave session JWT and enforce database-backed revocation. - * - * The verifier recomputes an HS256 signature before parsing claims, then requires - * the signed header to declare the same pinned algorithm and JWT type. Session - * claims must contain a positive safe-integer subject, a future safe-integer - * expiry, and a non-negative safe-integer token version. The referenced user must - * exist and the token version must equal the current database value. Every - * session-JWT transport uses this function so `logout-all` cannot be bypassed by - * calendar, SSE, attachment-view, or bearer-token routes. - * - * @param {unknown} token - Compact JWT supplied by a client. - * @returns {Record} Verified session claims. - * @throws {Error} If structure, signature, header, claims, expiry, user, or revocation checks fail. - */ export function verifyToken(token) { const parts = String(token || '').split('.'); if (parts.length !== 3) throw new Error('malformed token'); const [header, body, sig] = parts; - - // Recompute HS256 first; do not parse or trust attacker-controlled claims - // before the compact representation has authenticated successfully. + // Recompute HS256 signature; never read/trust the header's declared alg. const expected = createHmac('sha256', SECRET).update(`${header}.${body}`).digest('base64url'); - const actualSignature = Buffer.from(sig); - const expectedSignature = Buffer.from(expected); - if ( - actualSignature.length !== expectedSignature.length - || !timingSafeEqual(actualSignature, expectedSignature) - ) { - throw new Error('bad signature'); - } - - const headerClaims = JSON.parse(Buffer.from(header, 'base64url').toString()); - if (!isClaimsObject(headerClaims)) throw new Error('invalid token header'); - if (headerClaims.alg !== 'HS256') throw new Error('invalid token algorithm'); - if (headerClaims.typ !== 'JWT') throw new Error('invalid token type'); - + const a = Buffer.from(sig); + const b = Buffer.from(expected); + if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bad signature'); const payload = JSON.parse(Buffer.from(body, 'base64url').toString()); - if (!isClaimsObject(payload)) throw new Error('invalid session claims'); - if (!Number.isSafeInteger(payload.sub) || payload.sub < 1) { - throw new Error('invalid session subject'); - } - if (!Number.isSafeInteger(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) { - throw new Error('expired or invalid session expiry'); - } - if (!Number.isSafeInteger(payload.tv) || payload.tv < 0) { - throw new Error('invalid token version'); - } - - const user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); - if (!user) throw new Error('unknown session subject'); - if (payload.tv !== user.token_version) throw new Error('revoked session'); + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('expired'); return payload; } diff --git a/server/clearfolio.mjs b/server/clearfolio.mjs index 8933e961..ae5cd8f3 100644 --- a/server/clearfolio.mjs +++ b/server/clearfolio.mjs @@ -6,36 +6,17 @@ import { createHmac } from 'node:crypto'; const CF_URL = (process.env.CLEARFOLIO_URL || '').replace(/\/$/, ''); const CF_SECRET = process.env.CLEARFOLIO_HMAC_SECRET || ''; const PERMISSIONS = 'job:create,job:read,viewer:read,artifact-link:create'; -const CLEARFOLIO_JOB_STATUSES = new Set(['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']); -/** Whether the process uses the in-memory Clearfolio development adapter. */ export const clearfolioMock = !CF_URL; -/** - * Sign tenant claims using the Clearfolio HMAC interoperability contract. - * - * The payload is the newline-delimited tenant ID, subject ID, permissions, and - * issued-at epoch value. The signature is unpadded base64url HMAC-SHA256. - * - * @param {string} tenantId - Clearfolio tenant identifier. - * @param {string} subjectId - Clearfolio subject identifier. - * @param {string} permissions - Comma-separated permission contract. - * @param {string|number} issuedAt - Epoch-second issue time. - * @param {string} secret - Shared HMAC secret. - * @returns {string} Unpadded base64url signature. - */ +// Clearfolio TenantAccessService.signClaims와 동일한 규격: +// payload = tenantId \n subjectId \n permissions \n issuedAt(epoch초), +// HMAC-SHA256 → base64url(무패딩). export function signClaims(tenantId, subjectId, permissions, issuedAt, secret) { const payload = [tenantId, subjectId, permissions, issuedAt].join('\n'); return createHmac('sha256', secret).update(payload).digest('base64url'); } -/** - * Build tenant-scoped Clearfolio request headers without exposing credentials. - * - * @param {string|number} orgId - ScopeWeave organization identifier. - * @param {string|number} userId - Requesting ScopeWeave user identifier. - * @returns {Record} Tenant, subject, permission, and optional HMAC headers. - */ function tenantHeaders(orgId, userId) { const tenantId = `sw-org-${orgId}`; const subjectId = `sw-user-${userId}`; @@ -47,67 +28,16 @@ function tenantHeaders(orgId, userId) { if (CF_SECRET) { const issuedAt = String(Math.floor(Date.now() / 1000)); headers['X-Clearfolio-Claims-Issued-At'] = issuedAt; - headers['X-Clearfolio-Claims-Signature'] = signClaims( - tenantId, - subjectId, - PERMISSIONS, - issuedAt, - CF_SECRET, - ); + headers['X-Clearfolio-Claims-Signature'] = signClaims(tenantId, subjectId, PERMISSIONS, issuedAt, CF_SECRET); } return headers; } -/** - * Test whether an untrusted parsed JSON value is a plain record-like object. - * - * Arrays and null are rejected so property access cannot silently accept an - * incompatible downstream response shape. - * - * @param {unknown} value - Parsed downstream JSON value. - * @returns {value is Record} Whether the value is a non-array object. - */ -function isJsonRecord(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -/** - * Test whether an untrusted value is an exact Clearfolio conversion state. - * - * Whitespace-padded and unknown strings are rejected rather than normalized so - * the database cannot persist a state outside the documented workflow contract. - * - * @param {unknown} value - Parsed downstream status value. - * @returns {value is string} Whether the value is an exact accepted state. - */ -function isClearfolioJobStatus(value) { - return typeof value === 'string' && CLEARFOLIO_JOB_STATUSES.has(value); -} - // ---- mock store (dev/test 전용; 재시작 시 소실) ---- const mockDocs = new Map(); // jobId -> { name, mime, bytes } let mockSeq = 0; - -/** - * Read one in-memory mock artifact. - * - * @param {string} jobId - Mock conversion job identifier. - * @returns {{name:string,mime:string,bytes:Buffer}|null} Stored artifact or null. - */ export const mockArtifact = (jobId) => mockDocs.get(jobId) || null; -/** - * Submit a document conversion job through Clearfolio or the local mock. - * - * Downstream response text and transport errors are never copied into the - * thrown error because the caller may serialize that message to a browser. - * - * @param {string|number} orgId - ScopeWeave organization identifier. - * @param {string|number} userId - Requesting ScopeWeave user identifier. - * @param {{name:string,mime:string,bytes:Buffer|Uint8Array}} document - Conversion payload. - * @returns {Promise<{jobId:string,status:string}>} Downstream job identity and initial status. - * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed response. - */ export async function submitJob(orgId, userId, { name, mime, bytes }) { if (clearfolioMock) { const jobId = `mockcf-${++mockSeq}`; @@ -116,115 +46,41 @@ export async function submitJob(orgId, userId, { name, mime, bytes }) { } const form = new FormData(); form.append('file', new Blob([bytes], { type: mime || 'application/octet-stream' }), name); - let res; - try { - res = await fetch(`${CF_URL}/api/v1/convert/jobs`, { - method: 'POST', - headers: tenantHeaders(orgId, userId), - body: form, - }); - } catch { - throw new Error('clearfolio submit unavailable'); - } - if (!res.ok) throw new Error(`clearfolio submit failed (${res.status})`); - const data = await res.json().catch(() => null); - if (!isJsonRecord(data)) throw new Error('clearfolio submit response invalid'); - const status = data.status === undefined ? 'PENDING' : data.status; - if ( - typeof data.jobId !== 'string' - || data.jobId.trim().length === 0 - || !isClearfolioJobStatus(status) - ) { - throw new Error('clearfolio submit response invalid'); - } - return { jobId: data.jobId.trim(), status }; + const res = await fetch(`${CF_URL}/api/v1/convert/jobs`, { + method: 'POST', + headers: tenantHeaders(orgId, userId), + body: form, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.jobId) throw new Error(data.message || `clearfolio submit failed (${res.status})`); + return { jobId: data.jobId, status: data.status || 'PENDING' }; } -/** - * Read a Clearfolio conversion status with optional caller cancellation. - * - * Transport failures, non-success HTTP responses, and successful responses - * without an exact documented conversion state all throw fixed operation-level - * errors. The bounded refresh engine can therefore preserve the previously - * persisted state without logging or returning private downstream details. - * - * @param {string|number} orgId - ScopeWeave organization identifier. - * @param {string|number} userId - Requesting user identifier. - * @param {string} jobId - Clearfolio conversion job identifier. - * @param {{signal?:AbortSignal}} [options] - Optional request cancellation signal. - * @returns {Promise} Validated downstream conversion status. - * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns a malformed status. - */ -export async function jobStatus(orgId, userId, jobId, { signal } = {}) { +export async function jobStatus(orgId, userId, jobId) { if (clearfolioMock) return mockDocs.has(jobId) ? 'SUCCEEDED' : 'FAILED'; - let res; - try { - res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { - headers: tenantHeaders(orgId, userId), - signal, - }); - } catch { - throw new Error('clearfolio status unavailable'); - } - if (!res.ok) throw new Error(`clearfolio status failed (${res.status})`); - const data = await res.json().catch(() => null); - if (!isJsonRecord(data) || !isClearfolioJobStatus(data.status)) { - throw new Error('clearfolio status response invalid'); - } - return data.status; + const res = await fetch(`${CF_URL}/api/v1/convert/jobs/${encodeURIComponent(jobId)}`, { + headers: tenantHeaders(orgId, userId), + }); + const data = await res.json().catch(() => ({})); + return data.status || 'FAILED'; } -/** - * Issue a viewable artifact URL for a completed Clearfolio job. - * - * The hosted path prefers Clearfolio's external PDF.js viewer when an - * `artifactToken` is available and otherwise returns a validated HTTP(S) URL. - * Downstream response text and transport errors are never exposed to callers. - * An HTTPS Clearfolio deployment cannot downgrade an artifact link to HTTP. - * - * @param {string|number} orgId - ScopeWeave organization identifier. - * @param {string|number} userId - Requesting ScopeWeave user identifier. - * @param {string} jobId - Completed conversion job identifier. - * @returns {Promise} Relative mock path or validated absolute artifact URL. - * @throws {Error} If Clearfolio is unavailable, rejects the request, or returns an invalid link. - */ +// SUCCEEDED 잡의 서명 아티팩트 URL 발급 → 뷰어/직접 열람용 절대 URL 반환. export async function artifactUrl(orgId, userId, jobId) { if (clearfolioMock) return `/api/mock-clearfolio/${encodeURIComponent(jobId)}`; - let res; - try { - res = await fetch(`${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { - method: 'POST', - headers: tenantHeaders(orgId, userId), - }); - } catch { - throw new Error('clearfolio artifact-link unavailable'); - } - if (!res.ok) throw new Error(`clearfolio artifact-link failed (${res.status})`); - const data = await res.json().catch(() => null); - if (!isJsonRecord(data)) throw new Error('clearfolio artifact-link response invalid'); + const res = await fetch(`${CF_URL}/api/v1/viewer/${encodeURIComponent(jobId)}/artifact-links`, { + method: 'POST', + headers: tenantHeaders(orgId, userId), + }); + const data = await res.json().catch(() => ({})); const link = data.artifactUrl || data.url || data.signedUrl; - if (typeof link !== 'string' || link.length === 0) { - throw new Error('clearfolio artifact-link response invalid'); - } - - let url; - let clearfolioUrl; - try { - clearfolioUrl = new URL(CF_URL); - url = new URL(link, clearfolioUrl); - } catch { - throw new Error('clearfolio artifact-link response invalid'); - } - const allowsHttp = clearfolioUrl.protocol === 'http:' && url.protocol === 'http:'; - if (url.protocol !== 'https:' && !allowsHttp) { - throw new Error('clearfolio artifact-link response invalid'); - } - + if (!res.ok || !link) throw new Error(data.message || `clearfolio artifact-link failed (${res.status})`); // PDF.js 뷰어 페이지 우선(clearfolio external artifactToken 모드): 토큰을 - // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 없으면 검증한 URL. - const token = url.searchParams.get('artifactToken'); - if (token) { - return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(token)}`; - } - return url.href; + // 추출해 /viewer/{docId}?artifactToken=… 으로 보낸다. 실패 시 원시 아티팩트. + try { + const u = new URL(link, CF_URL); + const tok = u.searchParams.get('artifactToken'); + if (tok) return `${CF_URL}/viewer/${encodeURIComponent(jobId)}?artifactToken=${encodeURIComponent(tok)}`; + } catch { /* fall through to raw link */ } + return link.startsWith('http') ? link : `${CF_URL}${link}`; } diff --git a/server/oidc.mjs b/server/oidc.mjs new file mode 100644 index 00000000..d5764dca --- /dev/null +++ b/server/oidc.mjs @@ -0,0 +1,657 @@ +import { + createPublicKey, + timingSafeEqual, + verify as verifySignature, +} from 'node:crypto'; + +const RAW_ISSUER = String(process.env.OIDC_ISSUER || '').trim(); +const CLIENT_ID = String(process.env.OIDC_CLIENT_ID || '').trim(); +const CLIENT_SECRET = String(process.env.OIDC_CLIENT_SECRET || '').trim(); +const REDIRECT_URI = String(process.env.OIDC_REDIRECT_URI || '').trim(); +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_PROVIDER_RESPONSE_BYTES = 1024 * 1024; +const MAX_ID_TOKEN_BYTES = 128 * 1024; +const CLOCK_SKEW_SECONDS = 60; +const CACHE_TTL_MS = 5 * 60 * 1000; + +export const oidcMock = process.env.SCOPEWEAVE_DEV === '1' && !RAW_ISSUER; + +/** Stable, operator-safe failure raised by the OpenID Connect trust boundary. */ +export class OidcConfigurationError extends Error { + /** + * Create one OIDC error. + * @param {string} code machine-readable failure code + * @param {string} message operator-safe detail + * @param {number} statusCode HTTP status suitable for the relying-party API + */ + constructor(code, message, statusCode = 400) { + super(message); + this.name = 'OidcConfigurationError'; + this.code = code; + this.statusCode = statusCode; + } +} + +/** + * Return whether an HTTP endpoint is permitted for OIDC transport. + * @param {URL} url parsed endpoint + * @returns {boolean} + */ +function isSecureEndpoint(url) { + return url.protocol === 'https:' + || ( + url.protocol === 'http:' + && ['localhost', '127.0.0.1', '::1'].includes(url.hostname) + ); +} + +/** + * Parse and validate one OIDC URL. + * @param {string} value candidate URL + * @param {string} code failure-code prefix + * @param {{allowQuery?: boolean}} options URL policy + * @returns {URL} + */ +function validatedUrl(value, code, { allowQuery = false } = {}) { + let url; + try { + url = new URL(value); + } catch { + throw new OidcConfigurationError( + `${code}_invalid`, + `${code} must be a valid absolute URL.`, + 503, + ); + } + if ( + !isSecureEndpoint(url) + || url.username + || url.password + || url.hash + || (!allowQuery && url.search) + ) { + throw new OidcConfigurationError( + `${code}_invalid`, + `${code} violates the OIDC transport or URL policy.`, + 503, + ); + } + return url; +} + +/** + * Resolve explicit development mode or complete production relying-party configuration. + * @returns {{mock: true} | {mock: false, issuer: string, clientId: string, clientSecret: string, redirectUri: string}} + */ +function oidcConfiguration() { + if (oidcMock) return { mock: true }; + if (!RAW_ISSUER) { + throw new OidcConfigurationError( + 'oidc_not_configured', + 'OpenID Connect is unavailable because OIDC_ISSUER is not configured.', + 503, + ); + } + if (!CLIENT_ID || !CLIENT_SECRET || !REDIRECT_URI) { + throw new OidcConfigurationError( + 'oidc_configuration_incomplete', + 'OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_REDIRECT_URI are required.', + 503, + ); + } + const issuerUrl = validatedUrl(RAW_ISSUER, 'oidc_issuer'); + const redirectUrl = validatedUrl(REDIRECT_URI, 'oidc_redirect_uri', { + allowQuery: true, + }); + const issuer = issuerUrl.toString().replace(/\/$/, ''); + return { + mock: false, + issuer, + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + redirectUri: redirectUrl.toString(), + }; +} + +/** + * Validate one bounded opaque protocol value. + * @param {unknown} value candidate value + * @param {string} field field name + * @param {number} minimumLength minimum accepted length + * @param {number} maximumLength maximum accepted length + * @returns {string} + */ +function boundedProtocolValue(value, field, minimumLength, maximumLength) { + if ( + typeof value !== 'string' + || value.length < minimumLength + || value.length > maximumLength + || !/^[A-Za-z0-9._~-]+$/.test(value) + ) { + throw new OidcConfigurationError( + `oidc_${field}_invalid`, + `OIDC ${field} is outside the accepted boundary.`, + ); + } + return value; +} + +/** + * Fetch one bounded JSON object from the provider. + * @param {string} url provider endpoint + * @param {RequestInit} init request options + * @param {string} failurePrefix failure-code prefix + * @returns {Promise>} + */ +async function fetchJson(url, init, failurePrefix) { + if (typeof globalThis.fetch !== 'function') { + throw new OidcConfigurationError( + `${failurePrefix}_transport_unavailable`, + 'OIDC HTTP transport is unavailable.', + 503, + ); + } + let response; + try { + response = await globalThis.fetch(url, { + ...init, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch { + throw new OidcConfigurationError( + `${failurePrefix}_unavailable`, + 'The OpenID Provider could not be reached.', + 502, + ); + } + let bytes; + try { + bytes = Buffer.from(await response.arrayBuffer()); + } catch { + throw new OidcConfigurationError( + `${failurePrefix}_response_invalid`, + 'The OpenID Provider response could not be read.', + 502, + ); + } + if (bytes.length === 0 || bytes.length > MAX_PROVIDER_RESPONSE_BYTES) { + throw new OidcConfigurationError( + `${failurePrefix}_response_size_invalid`, + 'The OpenID Provider response size is outside the accepted boundary.', + 502, + ); + } + let payload; + try { + payload = JSON.parse(bytes.toString('utf8')); + } catch { + throw new OidcConfigurationError( + `${failurePrefix}_response_invalid`, + 'The OpenID Provider returned non-JSON data.', + 502, + ); + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new OidcConfigurationError( + `${failurePrefix}_response_invalid`, + 'The OpenID Provider returned an invalid JSON object.', + 502, + ); + } + if (!response.ok) { + throw new OidcConfigurationError( + `${failurePrefix}_rejected`, + `The OpenID Provider rejected the request with HTTP ${response.status}.`, + 502, + ); + } + return payload; +} + +let discoveryCache = null; +let jwksCache = null; + +/** + * Validate provider metadata and bind it to the configured issuer. + * @param {Record} metadata discovery document + * @param {string} issuer configured issuer + * @returns {{issuer: string, authorizationEndpoint: string, tokenEndpoint: string, jwksUri: string, tokenAuthMethods: string[]}} + */ +function validatedDiscovery(metadata, issuer) { + if (metadata.issuer !== issuer) { + throw new OidcConfigurationError( + 'oidc_discovery_issuer_mismatch', + 'OIDC discovery issuer does not exactly match OIDC_ISSUER.', + 502, + ); + } + const authorizationEndpoint = validatedUrl( + String(metadata.authorization_endpoint || ''), + 'oidc_authorization_endpoint', + { allowQuery: true }, + ).toString(); + const tokenEndpoint = validatedUrl( + String(metadata.token_endpoint || ''), + 'oidc_token_endpoint', + { allowQuery: true }, + ).toString(); + const jwksUri = validatedUrl( + String(metadata.jwks_uri || ''), + 'oidc_jwks_uri', + { allowQuery: true }, + ).toString(); + const signingAlgorithms = metadata.id_token_signing_alg_values_supported; + if ( + Array.isArray(signingAlgorithms) + && !signingAlgorithms.includes('RS256') + ) { + throw new OidcConfigurationError( + 'oidc_rs256_unsupported', + 'The OpenID Provider does not advertise RS256 ID Token signing.', + 502, + ); + } + const tokenAuthMethods = Array.isArray( + metadata.token_endpoint_auth_methods_supported, + ) + ? metadata.token_endpoint_auth_methods_supported.filter( + (method) => typeof method === 'string', + ) + : ['client_secret_basic']; + if ( + !tokenAuthMethods.includes('client_secret_basic') + && !tokenAuthMethods.includes('client_secret_post') + ) { + throw new OidcConfigurationError( + 'oidc_token_auth_unsupported', + 'The OpenID Provider supports no configured client-secret authentication method.', + 502, + ); + } + return { + issuer, + authorizationEndpoint, + tokenEndpoint, + jwksUri, + tokenAuthMethods, + }; +} + +/** + * Load and cache exact issuer discovery metadata. + * @param {ReturnType} configuration production configuration + * @returns {Promise>} + */ +async function providerDiscovery(configuration) { + const now = Date.now(); + if ( + discoveryCache + && discoveryCache.issuer === configuration.issuer + && discoveryCache.expiresAt > now + ) { + return discoveryCache.value; + } + const metadata = await fetchJson( + `${configuration.issuer}/.well-known/openid-configuration`, + { headers: { accept: 'application/json' } }, + 'oidc_discovery', + ); + const value = validatedDiscovery(metadata, configuration.issuer); + discoveryCache = { + issuer: configuration.issuer, + expiresAt: now + CACHE_TTL_MS, + value, + }; + return value; +} + +/** + * Return a provider authorization URL bound to state, nonce, and S256 PKCE. + * @param {{state: string, nonce: string, codeChallenge: string}} request authorization request values + * @returns {Promise<{url: string, redirectUri: string}>} + */ +export async function authorizationUrl({ state, nonce, codeChallenge }) { + const configuration = oidcConfiguration(); + if (configuration.mock) { + throw new OidcConfigurationError( + 'oidc_development_route_required', + 'Development OIDC must use the local explicit mock route.', + 500, + ); + } + const safeState = boundedProtocolValue(state, 'state', 32, 256); + const safeNonce = boundedProtocolValue(nonce, 'nonce', 32, 256); + const safeChallenge = boundedProtocolValue( + codeChallenge, + 'code_challenge', + 43, + 128, + ); + const discovery = await providerDiscovery(configuration); + const url = new URL(discovery.authorizationEndpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'openid email profile'); + url.searchParams.set('client_id', configuration.clientId); + url.searchParams.set('redirect_uri', configuration.redirectUri); + url.searchParams.set('state', safeState); + url.searchParams.set('nonce', safeNonce); + url.searchParams.set('code_challenge', safeChallenge); + url.searchParams.set('code_challenge_method', 'S256'); + return { url: url.toString(), redirectUri: configuration.redirectUri }; +} + +/** + * Decode one base64url JSON JWT segment. + * @param {string} segment compact JWT segment + * @param {string} label segment label + * @returns {Record} + */ +function decodeJwtObject(segment, label) { + if (!/^[A-Za-z0-9_-]+$/.test(segment)) { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + `ID Token ${label} is not valid base64url.`, + ); + } + let payload; + try { + payload = JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); + } catch { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + `ID Token ${label} is not valid JSON.`, + ); + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + `ID Token ${label} is not an object.`, + ); + } + return payload; +} + +/** + * Compare bounded protocol strings without exposing early length-based timing. + * @param {unknown} actual actual claim + * @param {string} expected expected claim + * @returns {boolean} + */ +function constantTimeStringEqual(actual, expected) { + if (typeof actual !== 'string') return false; + const actualBytes = Buffer.from(actual); + const expectedBytes = Buffer.from(expected); + if (actualBytes.length !== expectedBytes.length) return false; + return timingSafeEqual(actualBytes, expectedBytes); +} + +/** + * Validate an RS256 ID Token and required identity claims. + * @param {{idToken: string, jwks: Record, issuer: string, clientId: string, nonce: string, nowSeconds?: number}} input verification input + * @returns {{email: string, subject: string, claims: Record}} + */ +export function verifyIdToken({ + idToken, + jwks, + issuer, + clientId, + nonce, + nowSeconds = Math.floor(Date.now() / 1000), +}) { + if ( + typeof idToken !== 'string' + || idToken.length === 0 + || Buffer.byteLength(idToken) > MAX_ID_TOKEN_BYTES + ) { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + 'ID Token is missing or outside the accepted size boundary.', + ); + } + const parts = idToken.split('.'); + if (parts.length !== 3 || parts.some((part) => !part)) { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + 'ID Token must use compact JWS serialization.', + ); + } + const [encodedHeader, encodedClaims, encodedSignature] = parts; + const header = decodeJwtObject(encodedHeader, 'header'); + const claims = decodeJwtObject(encodedClaims, 'claims'); + if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) { + throw new OidcConfigurationError( + 'oidc_id_token_algorithm_invalid', + 'ID Token must use an identified RS256 signing key.', + ); + } + const keys = Array.isArray(jwks?.keys) ? jwks.keys : []; + const key = keys.find( + (candidate) => candidate + && typeof candidate === 'object' + && candidate.kid === header.kid + && candidate.kty === 'RSA' + && (candidate.use == null || candidate.use === 'sig') + && (candidate.alg == null || candidate.alg === 'RS256'), + ); + if (!key) { + throw new OidcConfigurationError( + 'oidc_signing_key_not_found', + 'No trusted RS256 signing key matches the ID Token.', + ); + } + let publicKey; + try { + publicKey = createPublicKey({ key, format: 'jwk' }); + } catch { + throw new OidcConfigurationError( + 'oidc_signing_key_invalid', + 'The provider signing key is invalid.', + 502, + ); + } + let signature; + try { + signature = Buffer.from(encodedSignature, 'base64url'); + } catch { + throw new OidcConfigurationError( + 'oidc_id_token_invalid', + 'ID Token signature is not valid base64url.', + ); + } + const validSignature = verifySignature( + 'RSA-SHA256', + Buffer.from(`${encodedHeader}.${encodedClaims}`), + publicKey, + signature, + ); + if (!validSignature) { + throw new OidcConfigurationError( + 'oidc_id_token_signature_invalid', + 'ID Token signature verification failed.', + ); + } + if (claims.iss !== issuer) { + throw new OidcConfigurationError( + 'oidc_id_token_issuer_invalid', + 'ID Token issuer does not match the discovered issuer.', + ); + } + const audiences = typeof claims.aud === 'string' + ? [claims.aud] + : Array.isArray(claims.aud) + ? claims.aud.filter((audience) => typeof audience === 'string') + : []; + if (!audiences.includes(clientId)) { + throw new OidcConfigurationError( + 'oidc_id_token_audience_invalid', + 'ID Token audience does not include this client.', + ); + } + if (audiences.length > 1 && claims.azp !== clientId) { + throw new OidcConfigurationError( + 'oidc_id_token_authorized_party_invalid', + 'ID Token authorized party does not identify this client.', + ); + } + if ( + !Number.isSafeInteger(claims.exp) + || claims.exp <= nowSeconds - CLOCK_SKEW_SECONDS + ) { + throw new OidcConfigurationError( + 'oidc_id_token_expired', + 'ID Token is expired or missing a valid expiration.', + ); + } + if ( + !Number.isSafeInteger(claims.iat) + || claims.iat > nowSeconds + CLOCK_SKEW_SECONDS + ) { + throw new OidcConfigurationError( + 'oidc_id_token_issued_at_invalid', + 'ID Token issued-at time is missing or in the future.', + ); + } + if ( + claims.nbf != null + && (!Number.isSafeInteger(claims.nbf) || claims.nbf > nowSeconds + CLOCK_SKEW_SECONDS) + ) { + throw new OidcConfigurationError( + 'oidc_id_token_not_before_invalid', + 'ID Token is not yet valid.', + ); + } + if (!constantTimeStringEqual(claims.nonce, nonce)) { + throw new OidcConfigurationError( + 'oidc_id_token_nonce_invalid', + 'ID Token nonce does not match the authorization request.', + ); + } + if ( + typeof claims.sub !== 'string' + || claims.sub.length === 0 + || claims.sub.length > 255 + ) { + throw new OidcConfigurationError( + 'oidc_id_token_subject_invalid', + 'ID Token subject is missing or invalid.', + ); + } + if ( + typeof claims.email !== 'string' + || claims.email.length > 320 + || !/^[^\s@]+@[^\s@]+$/.test(claims.email) + || claims.email_verified !== true + ) { + throw new OidcConfigurationError( + 'oidc_id_token_email_invalid', + 'ID Token must contain a verified email address.', + ); + } + return { + email: claims.email.toLowerCase(), + subject: claims.sub, + claims, + }; +} + +/** + * Load provider signing keys with a bounded cache. + * @param {string} jwksUri provider JWKS endpoint + * @returns {Promise>} + */ +async function providerJwks(jwksUri) { + const now = Date.now(); + if (jwksCache && jwksCache.uri === jwksUri && jwksCache.expiresAt > now) { + return jwksCache.value; + } + const value = await fetchJson( + jwksUri, + { headers: { accept: 'application/json' } }, + 'oidc_jwks', + ); + if (!Array.isArray(value.keys) || value.keys.length === 0 || value.keys.length > 100) { + throw new OidcConfigurationError( + 'oidc_jwks_invalid', + 'The OpenID Provider returned no bounded signing-key set.', + 502, + ); + } + jwksCache = { uri: jwksUri, expiresAt: now + CACHE_TTL_MS, value }; + return value; +} + +/** + * Exchange an authorization code and verify the returned ID Token. + * @param {{code: string, codeVerifier: string, nonce: string, redirectUri: string, nowSeconds?: number}} request callback values + * @returns {Promise<{email: string, subject: string, claims: Record}>} + */ +export async function exchangeAuthorizationCode({ + code, + codeVerifier, + nonce, + redirectUri, + nowSeconds = Math.floor(Date.now() / 1000), +}) { + const configuration = oidcConfiguration(); + if (configuration.mock) { + throw new OidcConfigurationError( + 'oidc_development_route_required', + 'Development OIDC must use the local explicit mock route.', + 500, + ); + } + const safeCode = boundedProtocolValue(code, 'authorization_code', 1, 4096); + const safeVerifier = boundedProtocolValue( + codeVerifier, + 'code_verifier', + 43, + 128, + ); + const safeNonce = boundedProtocolValue(nonce, 'nonce', 32, 256); + if (redirectUri !== configuration.redirectUri) { + throw new OidcConfigurationError( + 'oidc_redirect_uri_mismatch', + 'OIDC callback redirect URI does not match the registered URI.', + ); + } + const discovery = await providerDiscovery(configuration); + const form = new URLSearchParams({ + grant_type: 'authorization_code', + code: safeCode, + redirect_uri: configuration.redirectUri, + client_id: configuration.clientId, + code_verifier: safeVerifier, + }); + const headers = { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }; + if (discovery.tokenAuthMethods.includes('client_secret_basic')) { + headers.authorization = `Basic ${Buffer.from( + `${encodeURIComponent(configuration.clientId)}:${encodeURIComponent(configuration.clientSecret)}`, + ).toString('base64')}`; + } else { + form.set('client_secret', configuration.clientSecret); + } + const tokenResponse = await fetchJson( + discovery.tokenEndpoint, + { method: 'POST', headers, body: form.toString() }, + 'oidc_token', + ); + if (typeof tokenResponse.id_token !== 'string') { + throw new OidcConfigurationError( + 'oidc_id_token_missing', + 'The OpenID Provider returned no ID Token.', + 502, + ); + } + const jwks = await providerJwks(discovery.jwksUri); + return verifyIdToken({ + idToken: tokenResponse.id_token, + jwks, + issuer: discovery.issuer, + clientId: configuration.clientId, + nonce: safeNonce, + nowSeconds, + }); +} diff --git a/tests/api/attachment-status.test.mjs b/tests/api/attachment-status.test.mjs deleted file mode 100644 index 51bd5ea0..00000000 --- a/tests/api/attachment-status.test.mjs +++ /dev/null @@ -1,130 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -process.env.SCOPEWEAVE_DB = ':memory:'; -process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; -process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY = '2'; -process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS = '500'; -process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS = '1000'; -process.env.CLEARFOLIO_URL = ''; - -const { app } = await import('../../server/app.mjs'); -const { db } = await import('../../server/db.mjs'); -const jsonRequest = (path, options = {}) => app.request(path, { - ...options, - headers: { 'content-type': 'application/json', ...(options.headers || {}) }, -}); - -async function upload(projectId, token, taskId) { - const form = new FormData(); - form.append( - 'file', - new Blob([`content-${taskId}`], { type: 'text/plain' }), - `${taskId}.txt`, - ); - form.set('taskId', taskId); - const response = await app.request(`/api/projects/${projectId}/attachments`, { - method: 'POST', - headers: { authorization: `Bearer ${token}` }, - body: form, - }); - assert.equal(response.status, 200); - const payload = await response.json(); - assert.equal(Object.hasOwn(payload, 'jobId'), false); - return payload; -} - -test('attachment listing refreshes without internal identifier leakage', async () => { - let response = await jsonRequest('/api/auth/signup', { - method: 'POST', - body: JSON.stringify({ - email: 'attachments@scopeweave.test', - password: 'password123', - name: 'Attachments', - }), - }); - assert.equal(response.status, 200); - const token = (await response.json()).token; - const auth = { authorization: `Bearer ${token}` }; - - response = await jsonRequest('/api/me', { headers: auth }); - const userId = (await response.json()).user.id; - response = await jsonRequest('/api/projects', { - method: 'POST', - headers: auth, - body: JSON.stringify({ name: 'Attachment Status Project' }), - }); - assert.equal(response.status, 200); - const projectId = (await response.json()).id; - - const first = await upload(projectId, token, 'task-a'); - const second = await upload(projectId, token, 'task-b'); - db.prepare("UPDATE attachments SET status = 'PENDING' WHERE id IN (?, ?)") - .run(first.id, second.id); - db.prepare( - 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)', - ).run( - projectId, - 'task-missing', - 'missing.txt', - 'text/plain', - 1, - '', - 'PENDING', - userId, - ); - - response = await jsonRequest( - `/api/projects/${projectId}/attachments?taskId=task-a`, - { headers: auth }, - ); - assert.equal(response.status, 200); - let attachments = (await response.json()).attachments; - assert.equal(attachments.length, 1); - assert.equal(attachments[0].taskId, 'task-a'); - assert.equal(attachments[0].status, 'SUCCEEDED'); - assert.equal(Object.hasOwn(attachments[0], 'jobId'), false); - - response = await jsonRequest(`/api/projects/${projectId}/attachments`, { - headers: auth, - }); - assert.equal(response.status, 200); - attachments = (await response.json()).attachments; - assert.equal(attachments.length, 3); - assert.equal( - attachments.every((row) => !Object.hasOwn(row, 'jobId')), - true, - ); - assert.equal( - attachments.find((row) => row.taskId === 'task-b').status, - 'SUCCEEDED', - ); - assert.equal( - attachments.find((row) => row.taskId === 'task-missing').status, - 'PENDING', - ); - - response = await jsonRequest('/api/metrics'); - const metrics = await response.json(); - assert.equal(metrics.attachmentStatusRefreshAttempted, 2); - assert.equal(metrics.attachmentStatusRefreshChanged, 2); - assert.equal(metrics.attachmentStatusRefreshFailed, 0); - assert.equal(metrics.attachmentStatusRefreshSkipped, 1); - assert.equal(metrics.attachmentStatusRefreshDeferred, 0); - assert.equal(metrics.attachmentStatusRefreshTimeoutFailures, 0); - assert.equal(metrics.attachmentStatusRefreshDownstreamLookupFailures, 0); - assert.equal(metrics.attachmentStatusRefreshInvalidStatusFailures, 0); - assert.equal(metrics.attachmentStatusRefreshPersistenceFailures, 0); - - response = await jsonRequest('/api/metrics?format=prometheus'); - const prometheus = await response.text(); - assert.match(prometheus, /scopeweave_attachment_status_refresh_attempted 2/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_changed 2/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_failed 0/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_skipped 1/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_deferred 0/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_timeout_failures 0/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_downstream_lookup_failures 0/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_invalid_status_failures 0/); - assert.match(prometheus, /scopeweave_attachment_status_refresh_persistence_failures 0/); -}); diff --git a/tests/api/oidc-route.test.mjs b/tests/api/oidc-route.test.mjs new file mode 100644 index 00000000..4248152b --- /dev/null +++ b/tests/api/oidc-route.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { + generateKeyPairSync, + sign as signBytes, +} from 'node:crypto'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = 'scopeweave-oidc-route-test-secret-at-least-32-characters'; +process.env.OIDC_ISSUER = 'https://identity.example/tenant'; +process.env.OIDC_CLIENT_ID = 'scopeweave-client'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-client-secret'; +process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; +delete process.env.SCOPEWEAVE_DEV; + +const ISSUER = process.env.OIDC_ISSUER; +const CLIENT_ID = process.env.OIDC_CLIENT_ID; +const NOW_SECONDS = Math.floor(Date.now() / 1000); +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const jwk = { + ...publicKey.export({ format: 'jwk' }), + kid: 'route-key', + use: 'sig', + alg: 'RS256', +}; +let currentNonce = ''; +let tamperSignature = false; + +function encoded(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function idToken() { + const header = encoded({ alg: 'RS256', typ: 'JWT', kid: jwk.kid }); + const claims = encoded({ + iss: ISSUER, + sub: 'subject-route-42', + aud: CLIENT_ID, + exp: NOW_SECONDS + 600, + iat: NOW_SECONDS - 5, + nonce: currentNonce, + email: 'route-owner@example.com', + email_verified: true, + }); + const input = `${header}.${claims}`; + let signature = signBytes('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url'); + if (tamperSignature) signature = `${signature.slice(0, -1)}A`; + return `${input}.${signature}`; +} + +globalThis.fetch = async (url) => { + if (url === `${ISSUER}/.well-known/openid-configuration`) { + return new Response(JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + id_token_signing_alg_values_supported: ['RS256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + if (url === `${ISSUER}/token`) { + return new Response(JSON.stringify({ id_token: idToken() }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url === `${ISSUER}/jwks`) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected OIDC request: ${url}`); +}; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +async function startAuthorization() { + const response = await app.request( + 'https://scopeweave.example/api/auth/oidc/start', + ); + assert.equal(response.status, 302); + const location = new URL(response.headers.get('location')); + assert.equal(location.origin + location.pathname, `${ISSUER}/authorize`); + assert.equal(location.searchParams.get('code_challenge_method'), 'S256'); + currentNonce = location.searchParams.get('nonce'); + assert.ok(currentNonce); + return location.searchParams.get('state'); +} + +{ + const state = await startAuthorization(); + const response = await app.request( + `https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=route-code-1`, + ); + assert.equal(response.status, 302); + assert.match(response.headers.get('location'), /^\/#token=/); + assert.equal( + db.prepare('SELECT email FROM users WHERE email = ?').get('route-owner@example.com').email, + 'route-owner@example.com', + ); + + const replay = await app.request( + `https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=route-code-1`, + ); + assert.equal(replay.status, 400); + assert.deepEqual(await replay.json(), { error: 'invalid or expired state' }); +} + +{ + tamperSignature = true; + const state = await startAuthorization(); + const response = await app.request( + `https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=route-code-2`, + ); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { + error: 'oidc_id_token_signature_invalid', + }); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users WHERE email = ?').get('route-owner@example.com').count, + 1, + ); +} diff --git a/tests/api/session-revocation.test.mjs b/tests/api/session-revocation.test.mjs deleted file mode 100644 index 6164798b..00000000 --- a/tests/api/session-revocation.test.mjs +++ /dev/null @@ -1,208 +0,0 @@ -// Security invariant: logout-all revocation and strict session-claim validation -// must apply uniformly to every JWT transport. Calendar clients and EventSource -// cannot reliably send Authorization headers, so query-token routes must share -// the same fail-closed verifier as bearer middleware. -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createHmac } from 'node:crypto'; - -const JWT_SECRET = '0123456789abcdef0123456789abcdef'; -process.env.SCOPEWEAVE_DB = ':memory:'; -process.env.SCOPEWEAVE_JWT_SECRET = JWT_SECRET; - -const { app } = await import('../../server/app.mjs'); -const { signToken } = await import('../../server/auth.mjs'); - -const req = (path, opts = {}) => - app.request(path, { - ...opts, - headers: { 'content-type': 'application/json', ...(opts.headers || {}) }, - }); -const body = (value) => JSON.stringify(value); -const encodeSegment = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); - -/** - * Create a correctly signed but intentionally unvalidated compact JWT. - * - * Production code cannot mint malformed session claims through `signToken`. - * This test-only signer is therefore required to exercise the verifier's - * hostile-input boundary without weakening the production signer. - * - * @param {unknown} payload - Raw signed payload value. - * @param {unknown} [headerClaims] - Raw signed header value. - * @returns {string} Compact HS256 token signed with the test secret. - */ -function signUnsafe( - payload, - headerClaims = { alg: 'HS256', typ: 'JWT' }, -) { - const header = encodeSegment(headerClaims); - const encodedBody = encodeSegment(payload); - const signature = createHmac('sha256', JWT_SECRET) - .update(`${header}.${encodedBody}`) - .digest('base64url'); - return `${header}.${encodedBody}.${signature}`; -} - -async function expectStreamStatus(projectId, token, status, message) { - const response = await req( - `/api/projects/${projectId}/stream?token=${encodeURIComponent(token)}`, - ); - assert.equal(response.status, status, message); - await response.body?.cancel?.(); -} - -async function expectCalendarStatus(projectId, token, status, message) { - const response = await req( - `/api/projects/${projectId}/calendar.ics?token=${encodeURIComponent(token)}`, - ); - assert.equal(response.status, status, message); -} - -async function expectAttachmentViewStatus(projectId, token, status, message) { - const response = await req( - `/api/projects/${projectId}/attachments/missing/view?token=${encodeURIComponent(token)}`, - ); - assert.equal(response.status, status, message); -} - -async function expectBearerStatus(token, status, message) { - const response = await req('/api/me', { - headers: { authorization: `Bearer ${token}` }, - }); - assert.equal(response.status, status, message); -} - -/** - * Assert that one invalid session token is rejected by every supported transport. - * - * @param {number} projectId - Accessible project used by URL-token routes. - * @param {string} token - Invalid or revoked compact JWT. - * @param {string} label - Diagnostic label for assertion messages. - * @returns {Promise} Resolves after all four transport assertions. - */ -async function expectRejectedEverywhere(projectId, token, label) { - await expectBearerStatus(token, 401, `bearer rejects ${label}`); - await expectCalendarStatus(projectId, token, 401, `calendar rejects ${label}`); - await expectStreamStatus(projectId, token, 401, `SSE rejects ${label}`); - await expectAttachmentViewStatus(projectId, token, 401, `attachment view rejects ${label}`); -} - -test('session signer rejects malformed claims before minting a token', () => { - assert.throws(() => signToken(null), /claims must be an object/); - assert.throws(() => signToken([], 60), /claims must be an object/); - assert.throws(() => signToken({ sub: '1', tv: 0 }), /subject/); - assert.throws(() => signToken({ sub: 0, tv: 0 }), /subject/); - assert.throws( - () => signToken({ sub: Number.MAX_SAFE_INTEGER + 1, tv: 0 }), - /subject/, - ); - assert.throws(() => signToken({ sub: 1, tv: '0' }), /token version/); - assert.throws(() => signToken({ sub: 1, tv: -1 }), /token version/); - assert.throws( - () => signToken({ sub: 1, tv: Number.MAX_SAFE_INTEGER + 1 }), - /token version/, - ); - assert.throws(() => signToken({ sub: 1, tv: 0 }, '60'), /lifetime/); - assert.throws(() => signToken({ sub: 1, tv: 0 }, 0), /lifetime/); - assert.throws(() => signToken({ sub: 1, tv: 0 }, 1.5), /lifetime/); - assert.throws( - () => signToken({ sub: 1, tv: 0 }, 60 * 60 * 24 * 7 + 1), - /maximum lifetime/, - ); - assert.throws( - () => signToken({ sub: 1, tv: 0 }, Number.MAX_SAFE_INTEGER), - /maximum lifetime/, - ); -}); - -test('logout-all and strict JWT validation cover every session transport', async () => { - let response = await req('/api/auth/signup', { - method: 'POST', - body: body({ - email: 'revocation-test@scopeweave.test', - password: 'password123', - name: 'Revocation Test', - }), - }); - assert.equal(response.status, 200, 'signup succeeds'); - const tokenA = (await response.json()).token; - - const authA = { authorization: `Bearer ${tokenA}` }; - response = await req('/api/me', { headers: authA }); - assert.equal(response.status, 200, 'current session resolves the user'); - const userId = (await response.json()).user.id; - - response = await req('/api/projects', { - method: 'POST', - headers: authA, - body: body({ name: 'Revocation Probe' }), - }); - assert.equal(response.status, 200, 'project creation succeeds'); - const projectId = (await response.json()).id; - - response = await req('/api/auth/login', { - method: 'POST', - body: body({ - email: 'revocation-test@scopeweave.test', - password: 'password123', - }), - }); - assert.equal(response.status, 200, 'second-device login succeeds'); - const tokenB = (await response.json()).token; - - const now = Math.floor(Date.now() / 1000); - const validClaims = { sub: userId, tv: 0, iat: now, exp: now + 3_600 }; - const malformedTokens = [ - ['malformed compact token', 'not-a-jwt'], - ['invalid signature', `${tokenA.split('.').slice(0, 2).join('.')}.x`], - ['array header', signUnsafe(validClaims, [])], - ['non-HS256 header', signUnsafe(validClaims, { alg: 'none', typ: 'JWT' })], - ['non-JWT type', signUnsafe(validClaims, { alg: 'HS256', typ: 'JWS' })], - ['array claims', signUnsafe([])], - ['missing subject', signUnsafe({ tv: 0, iat: now, exp: now + 3_600 })], - ['string subject', signUnsafe({ ...validClaims, sub: '1' })], - ['zero subject', signUnsafe({ ...validClaims, sub: 0 })], - ['missing expiry', signUnsafe({ sub: userId, tv: 0, iat: now })], - ['string expiry', signUnsafe({ ...validClaims, exp: String(now + 3_600) })], - ['expired claim', signUnsafe({ ...validClaims, exp: now })], - ['missing token version', signUnsafe({ sub: userId, iat: now, exp: now + 3_600 })], - ['null token version', signUnsafe({ ...validClaims, tv: null })], - ['boolean token version', signUnsafe({ ...validClaims, tv: false })], - ['string token version', signUnsafe({ ...validClaims, tv: '0' })], - ['fractional token version', signUnsafe({ ...validClaims, tv: 0.5 })], - ['negative token version', signUnsafe({ ...validClaims, tv: -1 })], - ['unsafe token version', signUnsafe({ ...validClaims, tv: Number.MAX_SAFE_INTEGER + 1 })], - ]; - for (const [label, malformedToken] of malformedTokens) { - await expectRejectedEverywhere(projectId, malformedToken, label); - } - - const missingUserToken = signToken({ sub: userId + 1_000_000, tv: 0 }); - await expectRejectedEverywhere(projectId, missingUserToken, 'signed token for a missing user'); - - await expectBearerStatus(tokenA, 200, 'bearer accepts token A before revocation'); - await expectBearerStatus(tokenB, 200, 'bearer accepts token B before revocation'); - await expectCalendarStatus(projectId, tokenA, 200, 'calendar accepts token A before revocation'); - await expectCalendarStatus(projectId, tokenB, 200, 'calendar accepts token B before revocation'); - await expectStreamStatus(projectId, tokenA, 200, 'SSE accepts token A before revocation'); - await expectStreamStatus(projectId, tokenB, 200, 'SSE accepts token B before revocation'); - await expectAttachmentViewStatus(projectId, tokenA, 404, 'attachment view authenticates token A before lookup'); - await expectAttachmentViewStatus(projectId, tokenB, 404, 'attachment view authenticates token B before lookup'); - - response = await req('/api/auth/logout-all', { - method: 'POST', - headers: authA, - }); - assert.equal(response.status, 200, 'logout-all succeeds'); - const freshToken = (await response.json()).token; - - for (const [label, staleToken] of [['A', tokenA], ['B', tokenB]]) { - await expectRejectedEverywhere(projectId, staleToken, `stale token ${label}`); - } - - await expectBearerStatus(freshToken, 200, 'bearer accepts replacement token'); - await expectCalendarStatus(projectId, freshToken, 200, 'calendar accepts replacement token'); - await expectStreamStatus(projectId, freshToken, 200, 'SSE accepts replacement token'); - await expectAttachmentViewStatus(projectId, freshToken, 404, 'attachment view accepts replacement token before lookup'); -}); diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index 8cb0f4a2..84809c69 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -632,8 +632,7 @@ assert.equal(r.status, 404, 'non-member ai brief → 404'); r = await app.request(`/api/projects/${proj.id}/attachments`, { method: 'POST', headers: auth, body: fd }); assert.equal(r.status, 200, 'attachment upload'); const att = await r.json(); - assert.ok(att.id, 'attachment id'); - assert.equal(Object.hasOwn(att, 'jobId'), false, 'internal job id omitted'); + assert.ok(att.id && att.jobId.startsWith('mockcf-'), 'mock job id'); assert.equal(att.status, 'SUCCEEDED', 'mock converts immediately'); // 목록 + 작업 바인딩 + 업로더 r = await req(`/api/projects/${proj.id}/attachments?taskId=s1`, { headers: auth }); diff --git a/tests/unit/attachment-status.test.mjs b/tests/unit/attachment-status.test.mjs deleted file mode 100644 index 6423cc2e..00000000 --- a/tests/unit/attachment-status.test.mjs +++ /dev/null @@ -1,315 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ATTACHMENT_STATUS_MAX_BUDGET_MS, - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - normalizeAttachmentStatusBudgetMs, - normalizeAttachmentStatusConcurrency, - normalizeAttachmentStatusTimeoutMs, - refreshAttachmentStatuses, -} from '../../server/attachment_status.mjs'; - -test('attachment status configuration is bounded and fail-safe', () => { - assert.equal( - normalizeAttachmentStatusConcurrency(undefined), - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ); - assert.equal(normalizeAttachmentStatusConcurrency('4'), 4); - assert.equal( - normalizeAttachmentStatusConcurrency(0), - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ); - assert.equal( - normalizeAttachmentStatusConcurrency(1.5), - ATTACHMENT_STATUS_DEFAULT_CONCURRENCY, - ); - assert.equal( - normalizeAttachmentStatusConcurrency(999), - ATTACHMENT_STATUS_MAX_CONCURRENCY, - ); - - assert.equal( - normalizeAttachmentStatusTimeoutMs(undefined), - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ); - assert.equal(normalizeAttachmentStatusTimeoutMs('25'), 25); - assert.equal( - normalizeAttachmentStatusTimeoutMs(-1), - ATTACHMENT_STATUS_DEFAULT_TIMEOUT_MS, - ); - assert.equal( - normalizeAttachmentStatusTimeoutMs(50_000), - ATTACHMENT_STATUS_MAX_TIMEOUT_MS, - ); - - assert.equal( - normalizeAttachmentStatusBudgetMs(undefined), - ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, - ); - assert.equal(normalizeAttachmentStatusBudgetMs('2500'), 2_500); - assert.equal( - normalizeAttachmentStatusBudgetMs(0), - ATTACHMENT_STATUS_DEFAULT_BUDGET_MS, - ); - assert.equal( - normalizeAttachmentStatusBudgetMs(100_000), - ATTACHMENT_STATUS_MAX_BUDGET_MS, - ); -}); - -test('refresh validates its dependency, diagnostic, and clock contracts', async () => { - const dependencies = { - jobStatus: async () => 'PENDING', - updateStatus() {}, - }; - await assert.rejects( - () => refreshAttachmentStatuses(null, {}), - /rows must be an array/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], undefined), - /jobStatus must be a function/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], { updateStatus() {} }), - /jobStatus must be a function/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], { jobStatus() {} }), - /updateStatus must be a function/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], { ...dependencies, onError: 'log' }), - /onError must be a function/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], { ...dependencies, now: 1 }), - /now must be a function/, - ); - await assert.rejects( - () => refreshAttachmentStatuses([], { ...dependencies, now: () => Number.NaN }), - /clock must return a finite number/, - ); -}); - -test('empty and settled rows perform no downstream work', async () => { - const dependencies = { - jobStatus: async () => { throw new Error('must not run'); }, - updateStatus: () => { throw new Error('must not run'); }, - }; - assert.deepEqual(await refreshAttachmentStatuses([], dependencies), { - attempted: 0, - changed: 0, - failed: 0, - skipped: 0, - deferred: 0, - }); - - const metrics = {}; - assert.deepEqual( - await refreshAttachmentStatuses( - [null, { id: 1, status: 'SUCCEEDED', jobId: 'job-1' }], - { ...dependencies, metrics }, - ), - { attempted: 0, changed: 0, failed: 0, skipped: 0, deferred: 0 }, - ); - assert.deepEqual(metrics, { - attachmentStatusRefreshAttempted: 0, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 0, - attachmentStatusRefreshSkipped: 0, - attachmentStatusRefreshDeferred: 0, - attachmentStatusRefreshTimeoutFailures: 0, - attachmentStatusRefreshDownstreamLookupFailures: 0, - attachmentStatusRefreshInvalidStatusFailures: 0, - attachmentStatusRefreshPersistenceFailures: 0, - }); -}); - -test('100 pending rows reach but never exceed configured concurrency', async () => { - const rows = Array.from({ length: 100 }, (_, index) => ({ - id: index + 1, - jobId: `job-${index + 1}`, - status: index % 3 === 0 ? 'RUNNING' : 'PENDING', - })); - let active = 0; - let peak = 0; - let started = 0; - let releaseInitialWorkers; - const initialWorkerGate = new Promise((resolve) => { - releaseInitialWorkers = resolve; - }); - const updates = []; - const metrics = { - attachmentStatusRefreshAttempted: 10, - attachmentStatusRefreshChanged: 20, - attachmentStatusRefreshFailed: 30, - attachmentStatusRefreshSkipped: 35, - attachmentStatusRefreshDeferred: 40, - attachmentStatusRefreshTimeoutFailures: 1, - attachmentStatusRefreshDownstreamLookupFailures: 2, - attachmentStatusRefreshInvalidStatusFailures: 3, - attachmentStatusRefreshPersistenceFailures: 4, - }; - - const counts = await refreshAttachmentStatuses(rows, { - orgId: 7, - userId: 9, - concurrency: 8, - timeoutMs: 1_000, - budgetMs: 10_000, - metrics, - jobStatus: async (orgId, userId, jobId, { signal }) => { - assert.equal(orgId, 7); - assert.equal(userId, 9); - assert.equal(signal.aborted, false); - active += 1; - started += 1; - peak = Math.max(peak, active); - if (started === 8) releaseInitialWorkers(); - await initialWorkerGate; - active -= 1; - const rowNumber = Number(jobId.split('-')[1]); - return rowNumber % 2 === 0 ? 'SUCCEEDED' : rows[rowNumber - 1].status; - }, - updateStatus: async (status, attachmentId) => { - updates.push([status, attachmentId]); - }, - }); - - assert.equal(peak, 8, `peak concurrency ${peak} did not match configured limit`); - assert.deepEqual(counts, { - attempted: 100, - changed: 50, - failed: 0, - skipped: 0, - deferred: 0, - }); - assert.equal(updates.length, 50); - assert.deepEqual(metrics, { - attachmentStatusRefreshAttempted: 110, - attachmentStatusRefreshChanged: 70, - attachmentStatusRefreshFailed: 30, - attachmentStatusRefreshSkipped: 35, - attachmentStatusRefreshDeferred: 40, - attachmentStatusRefreshTimeoutFailures: 1, - attachmentStatusRefreshDownstreamLookupFailures: 2, - attachmentStatusRefreshInvalidStatusFailures: 3, - attachmentStatusRefreshPersistenceFailures: 4, - }); -}); - -test('request-wide deadline defers work that has not started', async () => { - const rows = [ - { id: 1, jobId: 'job-1', status: 'PENDING' }, - { id: 2, jobId: 'job-2', status: 'PENDING' }, - { id: 3, jobId: 'job-3', status: 'PENDING' }, - ]; - const clockValues = [0, 0, 20, 30]; - const counts = await refreshAttachmentStatuses(rows, { - concurrency: 1, - timeoutMs: 1_000, - budgetMs: 15, - now: () => clockValues.shift() ?? 30, - jobStatus: async () => 'PENDING', - updateStatus: () => { throw new Error('unchanged status must not be written'); }, - }); - - assert.deepEqual(counts, { - attempted: 1, - changed: 0, - failed: 0, - skipped: 0, - deferred: 2, - }); - assert.deepEqual(rows.map((row) => row.status), ['PENDING', 'PENDING', 'PENDING']); -}); - -test('invalid identifiers and categorized failures preserve stale state', async () => { - const rows = [ - { id: 1, jobId: null, status: 'PENDING' }, - { id: 2, jobId: '', status: 'RUNNING' }, - { id: 3, jobId: ' ', status: 'PENDING' }, - { id: 4, jobId: 'throws', status: 'PENDING' }, - { id: 5, jobId: 'invalid-status', status: 'PENDING' }, - { id: 6, jobId: 'write-fails', status: 'PENDING' }, - { id: 7, jobId: 'times-out', status: 'PENDING' }, - ]; - let aborted = false; - const categories = []; - const metrics = {}; - const counts = await refreshAttachmentStatuses(rows, { - concurrency: 3, - timeoutMs: 5, - budgetMs: 1_000, - metrics, - onError: ({ category }) => categories.push(category), - jobStatus: async (_orgId, _userId, jobId, { signal }) => { - if (jobId === 'throws') throw new Error('downstream failure with sensitive detail'); - if (jobId === 'invalid-status') return 'UNKNOWN'; - if (jobId === 'write-fails') return 'SUCCEEDED'; - return new Promise(() => { - signal.addEventListener('abort', () => { aborted = true; }, { once: true }); - }); - }, - updateStatus: (_status, attachmentId) => { - if (attachmentId === 6) throw new Error('write failure'); - }, - }); - - assert.equal(aborted, true); - assert.deepEqual(counts, { - attempted: 4, - changed: 0, - failed: 4, - skipped: 3, - deferred: 0, - }); - assert.deepEqual( - categories.sort(), - ['downstream_lookup', 'invalid_status', 'status_persistence', 'timeout'].sort(), - ); - assert.equal(categories.some((category) => category.includes('sensitive')), false); - assert.deepEqual(metrics, { - attachmentStatusRefreshAttempted: 4, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 4, - attachmentStatusRefreshSkipped: 3, - attachmentStatusRefreshDeferred: 0, - attachmentStatusRefreshTimeoutFailures: 1, - attachmentStatusRefreshDownstreamLookupFailures: 1, - attachmentStatusRefreshInvalidStatusFailures: 1, - attachmentStatusRefreshPersistenceFailures: 1, - }); - assert.equal(rows[5].status, 'PENDING'); - assert.equal(rows[6].status, 'PENDING'); -}); - -test('diagnostic sink failures and omitted diagnostics stay isolated', async () => { - const row = [{ id: 1, jobId: 'job-1', status: 'PENDING' }]; - const dependencies = { - timeoutMs: 100, - budgetMs: 1_000, - jobStatus: async () => { throw new Error('downstream failure'); }, - updateStatus() {}, - }; - - assert.deepEqual(await refreshAttachmentStatuses(row, dependencies), { - attempted: 1, - changed: 0, - failed: 1, - skipped: 0, - deferred: 0, - }); - assert.deepEqual( - await refreshAttachmentStatuses(row, { - ...dependencies, - onError: () => { throw new Error('logger unavailable'); }, - }), - { attempted: 1, changed: 0, failed: 1, skipped: 0, deferred: 0 }, - ); -}); diff --git a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs b/tests/unit/clearfolio-adapter-mock-hmac.test.mjs deleted file mode 100644 index 85ca5894..00000000 --- a/tests/unit/clearfolio-adapter-mock-hmac.test.mjs +++ /dev/null @@ -1,88 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -test('Clearfolio mock adapter preserves artifacts and local status semantics', async () => { - delete process.env.CLEARFOLIO_URL; - delete process.env.CLEARFOLIO_HMAC_SECRET; - const mock = await import('../../server/clearfolio.mjs?mock-adapter-contract-test=1'); - - assert.equal(mock.clearfolioMock, true); - assert.equal(mock.mockArtifact('missing-job'), null); - - const bytes = Buffer.from('mock document'); - const submitted = await mock.submitJob(11, 12, { - name: 'mock.txt', - mime: '', - bytes, - }); - assert.match(submitted.jobId, /^mockcf-\d+$/); - assert.equal(submitted.status, 'SUCCEEDED'); - assert.deepEqual(mock.mockArtifact(submitted.jobId), { - name: 'mock.txt', - mime: '', - bytes, - }); - assert.equal(await mock.jobStatus(11, 12, submitted.jobId), 'SUCCEEDED'); - assert.equal(await mock.jobStatus(11, 12, 'missing-job'), 'FAILED'); - assert.equal( - await mock.artifactUrl(11, 12, 'job/with space'), - '/api/mock-clearfolio/job%2Fwith%20space', - ); -}); - -test('Clearfolio tenant claim headers use the documented HMAC contract', async () => { - process.env.CLEARFOLIO_URL = 'https://clearfolio.example/'; - process.env.CLEARFOLIO_HMAC_SECRET = 'clearfolio-shared-secret'; - const originalFetch = globalThis.fetch; - const originalNow = Date.now; - let observedUrl; - let observedOptions; - Date.now = () => 1_750_000_000_000; - globalThis.fetch = async (url, options) => { - observedUrl = String(url); - observedOptions = options; - return { - ok: true, - status: 200, - json: async () => ({ status: 'RUNNING' }), - }; - }; - - try { - const signed = await import('../../server/clearfolio.mjs?hmac-header-contract-test=1'); - assert.equal(signed.clearfolioMock, false); - assert.equal(await signed.jobStatus(21, 34, 'signed-job'), 'RUNNING'); - assert.equal( - observedUrl, - 'https://clearfolio.example/api/v1/convert/jobs/signed-job', - ); - - const issuedAt = '1750000000'; - assert.equal(observedOptions.headers['X-Clearfolio-Tenant-Id'], 'sw-org-21'); - assert.equal(observedOptions.headers['X-Clearfolio-Subject-Id'], 'sw-user-34'); - assert.equal( - observedOptions.headers['X-Clearfolio-Permissions'], - 'job:create,job:read,viewer:read,artifact-link:create', - ); - assert.equal(observedOptions.headers['X-Clearfolio-Claims-Issued-At'], issuedAt); - assert.equal( - observedOptions.headers['X-Clearfolio-Claims-Signature'], - signed.signClaims( - 'sw-org-21', - 'sw-user-34', - 'job:create,job:read,viewer:read,artifact-link:create', - issuedAt, - 'clearfolio-shared-secret', - ), - ); - assert.doesNotMatch( - observedOptions.headers['X-Clearfolio-Claims-Signature'], - /=/, - ); - } finally { - Date.now = originalNow; - globalThis.fetch = originalFetch; - delete process.env.CLEARFOLIO_URL; - delete process.env.CLEARFOLIO_HMAC_SECRET; - } -}); diff --git a/tests/unit/clearfolio-status-signal.test.mjs b/tests/unit/clearfolio-status-signal.test.mjs deleted file mode 100644 index cf3ad02c..00000000 --- a/tests/unit/clearfolio-status-signal.test.mjs +++ /dev/null @@ -1,251 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; -const originalFetch = globalThis.fetch; -let observedUrl; -let observedOptions; -let downstreamResponse; -let downstreamError; - -globalThis.fetch = async (url, options = {}) => { - observedUrl = String(url); - observedOptions = options; - if (downstreamError) throw downstreamError; - return downstreamResponse; -}; - -const { artifactUrl, jobStatus, submitJob } = await import( - '../../server/clearfolio.mjs?downstream-contract-test=1' -); - -function setResponse({ ok = true, status = 200, json }) { - downstreamError = undefined; - downstreamResponse = { ok, status, json }; -} - -function setNetworkError(error) { - downstreamResponse = undefined; - downstreamError = error; -} - -async function expectSanitizedFailure(operation, expectedMessage, forbiddenPattern) { - await assert.rejects(operation, (error) => { - assert.equal(error.message, expectedMessage); - if (forbiddenPattern) assert.doesNotMatch(error.message, forbiddenPattern); - return true; - }); -} - -test.after(() => { - globalThis.fetch = originalFetch; - delete process.env.CLEARFOLIO_URL; -}); - -test('jobStatus enforces endpoint, signal, transport, HTTP, and status contracts', async () => { - setResponse({ json: async () => ({ status: 'RUNNING' }) }); - const controller = new AbortController(); - const status = await jobStatus(1, 2, 'job-1', { signal: controller.signal }); - assert.equal(status, 'RUNNING'); - assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs/job-1'); - assert.equal(observedOptions.signal, controller.signal); - - setNetworkError(new Error('connect ECONNREFUSED https://private-clearfolio.internal')); - await expectSanitizedFailure( - () => jobStatus(1, 2, 'job-1'), - 'clearfolio status unavailable', - /private-clearfolio|ECONNREFUSED/, - ); - - setResponse({ - ok: false, - status: 503, - json: async () => ({ message: 'sensitive downstream text' }), - }); - await expectSanitizedFailure( - () => jobStatus(1, 2, 'job-1'), - 'clearfolio status failed (503)', - /sensitive downstream text/, - ); - - const malformedPayloads = [ - { - label: 'unparseable JSON', - json: async () => { throw new SyntaxError('downstream parser detail'); }, - }, - { label: 'null body', json: async () => null }, - { label: 'primitive body', json: async () => 'RUNNING' }, - { label: 'array body', json: async () => [{ status: 'RUNNING' }] }, - { label: 'missing status', json: async () => ({}) }, - { label: 'non-string status', json: async () => ({ status: 200 }) }, - { label: 'empty status', json: async () => ({ status: '' }) }, - { label: 'whitespace status', json: async () => ({ status: ' ' }) }, - { label: 'padded status', json: async () => ({ status: ' RUNNING ' }) }, - { label: 'unknown status', json: async () => ({ status: 'QUEUED' }) }, - ]; - - for (const malformed of malformedPayloads) { - setResponse({ json: malformed.json }); - await expectSanitizedFailure( - () => jobStatus(1, 2, 'job-1'), - 'clearfolio status response invalid', - /downstream parser detail/, - ); - } -}); - -test('submitJob rejects transport details and malformed successful responses', async () => { - const document = { - name: 'status.txt', - mime: 'text/plain', - bytes: Buffer.from('status'), - }; - - setNetworkError(new Error('connect ECONNREFUSED https://private-clearfolio.internal')); - await expectSanitizedFailure( - () => submitJob(7, 9, document), - 'clearfolio submit unavailable', - /private-clearfolio|ECONNREFUSED/, - ); - - setResponse({ - ok: false, - status: 422, - json: async () => ({ message: 'tenant-internal rejection detail' }), - }); - await expectSanitizedFailure( - () => submitJob(7, 9, document), - 'clearfolio submit failed (422)', - /tenant-internal rejection detail/, - ); - assert.equal(observedUrl, 'https://clearfolio.example/api/v1/convert/jobs'); - assert.equal(observedOptions.method, 'POST'); - assert.ok(observedOptions.body instanceof FormData); - - const malformedPayloads = [ - { - label: 'unparseable JSON', - json: async () => { throw new SyntaxError('private parser detail'); }, - }, - { label: 'null body', json: async () => null }, - { label: 'primitive body', json: async () => 'job-1' }, - { label: 'array body', json: async () => [{ jobId: 'job-1' }] }, - { label: 'missing jobId', json: async () => ({ status: 'PENDING' }) }, - { label: 'non-string jobId', json: async () => ({ jobId: 7 }) }, - { label: 'blank jobId', json: async () => ({ jobId: ' ' }) }, - { label: 'non-string status', json: async () => ({ jobId: 'job-1', status: 7 }) }, - { label: 'empty status', json: async () => ({ jobId: 'job-1', status: '' }) }, - { label: 'whitespace status', json: async () => ({ jobId: 'job-1', status: ' ' }) }, - { label: 'padded status', json: async () => ({ jobId: 'job-1', status: ' RUNNING ' }) }, - { label: 'unknown status', json: async () => ({ jobId: 'job-1', status: 'QUEUED' }) }, - ]; - - for (const malformed of malformedPayloads) { - setResponse({ json: malformed.json }); - await expectSanitizedFailure( - () => submitJob(7, 9, document), - 'clearfolio submit response invalid', - /private parser detail/, - ); - } - - setResponse({ json: async () => ({ jobId: ' job-2 ' }) }); - assert.deepEqual(await submitJob(7, 9, document), { - jobId: 'job-2', - status: 'PENDING', - }); - - setResponse({ json: async () => ({ jobId: 'job-3', status: 'RUNNING' }) }); - assert.deepEqual(await submitJob(7, 9, document), { - jobId: 'job-3', - status: 'RUNNING', - }); -}); - -test('artifactUrl validates links and never exposes transport or response text', async () => { - setNetworkError(new Error('getaddrinfo ENOTFOUND private-clearfolio.internal')); - await expectSanitizedFailure( - () => artifactUrl(4, 5, 'job-1'), - 'clearfolio artifact-link unavailable', - /private-clearfolio|ENOTFOUND/, - ); - - setResponse({ - ok: false, - status: 502, - json: async () => ({ message: 'signed URL service secret detail' }), - }); - await expectSanitizedFailure( - () => artifactUrl(4, 5, 'job-1'), - 'clearfolio artifact-link failed (502)', - /signed URL service secret detail/, - ); - assert.equal( - observedUrl, - 'https://clearfolio.example/api/v1/viewer/job-1/artifact-links', - ); - assert.equal(observedOptions.method, 'POST'); - - const malformedPayloads = [ - { - label: 'unparseable JSON', - json: async () => { throw new SyntaxError('private artifact parser detail'); }, - }, - { label: 'null body', json: async () => null }, - { label: 'primitive body', json: async () => '/signed/file.pdf' }, - { label: 'array body', json: async () => [{ url: '/signed/file.pdf' }] }, - { label: 'missing link', json: async () => ({}) }, - { label: 'non-string link', json: async () => ({ artifactUrl: 42 }) }, - { label: 'empty link', json: async () => ({ artifactUrl: '' }) }, - { label: 'malformed URL', json: async () => ({ artifactUrl: 'http://[' }) }, - { label: 'unsupported URL scheme', json: async () => ({ artifactUrl: 'javascript:alert(1)' }) }, - { label: 'HTTPS downgrade', json: async () => ({ artifactUrl: 'http://cdn.example/file.pdf' }) }, - ]; - - for (const malformed of malformedPayloads) { - setResponse({ json: malformed.json }); - await expectSanitizedFailure( - () => artifactUrl(4, 5, 'job-1'), - 'clearfolio artifact-link response invalid', - /private artifact parser detail/, - ); - } - - setResponse({ json: async () => ({ artifactUrl: '/signed/file.pdf' }) }); - assert.equal( - await artifactUrl(4, 5, 'job-1'), - 'https://clearfolio.example/signed/file.pdf', - ); - - setResponse({ json: async () => ({ url: 'https://cdn.example/file.pdf' }) }); - assert.equal( - await artifactUrl(4, 5, 'job-1'), - 'https://cdn.example/file.pdf', - ); - - setResponse({ - json: async () => ({ - signedUrl: 'https://cdn.example/file.pdf?artifactToken=token%20value', - }), - }); - assert.equal( - await artifactUrl(4, 5, 'job-1'), - 'https://clearfolio.example/viewer/job-1?artifactToken=token%20value', - ); -}); - -test('artifactUrl permits HTTP only when the configured Clearfolio endpoint is HTTP', async () => { - process.env.CLEARFOLIO_URL = 'http://clearfolio.local'; - try { - const { artifactUrl: httpArtifactUrl } = await import( - '../../server/clearfolio.mjs?http-artifact-contract-test=1' - ); - setResponse({ json: async () => ({ artifactUrl: 'http://cdn.local/file.pdf' }) }); - assert.equal( - await httpArtifactUrl(4, 5, 'job-http'), - 'http://cdn.local/file.pdf', - ); - } finally { - process.env.CLEARFOLIO_URL = 'https://clearfolio.example'; - } -}); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs deleted file mode 100644 index 149440e5..00000000 --- a/tests/unit/coverage-script-contract.test.mjs +++ /dev/null @@ -1,48 +0,0 @@ -// This contract prevents a subtle CI regression: the central review gate may -// invoke `test:coverage` directly, so that script itself must create Istanbul -// JSON rather than merely execute tests without instrumentation. -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -const packageJson = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), -); -const scripts = packageJson.scripts; - -assert.equal( - scripts.coverage, - 'npm run test:coverage', - 'the public coverage command delegates to the canonical coverage producer', -); -assert.match( - scripts['test:coverage'], - /\bc8\b.*--reporter=json(?![-\w]).*npm run test:coverage:cases/, - 'test:coverage creates Istanbul JSON before executing coverage cases', -); -assert.match( - scripts['test:coverage'], - /--reporter=json-summary\b/, - 'test:coverage also creates the Istanbul JSON summary', -); -assert.match( - scripts['test:coverage'], - /--include=server\/attachment_status\.mjs/, - 'the bounded refresh module is instrumented', -); -assert.match( - scripts['test:coverage'], - /--include=server\/clearfolio\.mjs/, - 'the abortable Clearfolio adapter is instrumented', -); -assert.match( - scripts['test:coverage:cases'], - /tests\/unit\/clearfolio-status-signal\.test\.mjs/, - 'the Clearfolio signal and HTTP failure regression executes under c8', -); -assert.doesNotMatch( - scripts['test:coverage:cases'], - /npm run (?:coverage|test:coverage)(?:\s|$)/, - 'coverage cases never recursively invoke a coverage wrapper', -); - -console.log('✓ coverage script contract tests passed'); diff --git a/tests/unit/oidc.test.mjs b/tests/unit/oidc.test.mjs new file mode 100644 index 00000000..0da37b39 --- /dev/null +++ b/tests/unit/oidc.test.mjs @@ -0,0 +1,311 @@ +import assert from 'node:assert/strict'; +import { + generateKeyPairSync, + sign as signBytes, +} from 'node:crypto'; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_FETCH = globalThis.fetch; +const ISSUER = 'https://identity.example/tenant'; +const CLIENT_ID = 'scopeweave-client'; +const CLIENT_SECRET = 'scopeweave-client-secret'; +const REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; +const NOW_SECONDS = 1_786_291_200; +const STATE = 'state_abcdefghijklmnopqrstuvwxyz0123456789'; +const NONCE = 'nonce_abcdefghijklmnopqrstuvwxyz0123456789'; +const CHALLENGE = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG'; +const VERIFIER = 'abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-._~'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, +}); +const PUBLIC_JWK = { + ...publicKey.export({ format: 'jwk' }), + kid: 'scopeweave-test-key', + use: 'sig', + alg: 'RS256', +}; + +function restoreEnvironment() { + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + Object.assign(process.env, ORIGINAL_ENV); + globalThis.fetch = ORIGINAL_FETCH; +} + +function encodeJson(value) { + return Buffer.from(JSON.stringify(value)).toString('base64url'); +} + +function signedIdToken({ claims = {}, header = {}, signingKey = privateKey } = {}) { + const encodedHeader = encodeJson({ + alg: 'RS256', + typ: 'JWT', + kid: PUBLIC_JWK.kid, + ...header, + }); + const encodedClaims = encodeJson({ + iss: ISSUER, + sub: 'subject-42', + aud: CLIENT_ID, + exp: NOW_SECONDS + 600, + iat: NOW_SECONDS - 10, + nonce: NONCE, + email: 'Owner@Example.com', + email_verified: true, + ...claims, + }); + const signingInput = `${encodedHeader}.${encodedClaims}`; + const signature = signBytes( + 'RSA-SHA256', + Buffer.from(signingInput), + signingKey, + ).toString('base64url'); + return `${signingInput}.${signature}`; +} + +async function freshModule(label) { + return import(`../../server/oidc.mjs?test=${label}-${Date.now()}-${Math.random()}`); +} + +function clearOidcEnvironment() { + delete process.env.OIDC_ISSUER; + delete process.env.OIDC_CLIENT_ID; + delete process.env.OIDC_CLIENT_SECRET; + delete process.env.OIDC_REDIRECT_URI; + delete process.env.SCOPEWEAVE_DEV; +} + +function configureProduction() { + process.env.OIDC_ISSUER = ISSUER; + process.env.OIDC_CLIENT_ID = CLIENT_ID; + process.env.OIDC_CLIENT_SECRET = CLIENT_SECRET; + process.env.OIDC_REDIRECT_URI = REDIRECT_URI; + delete process.env.SCOPEWEAVE_DEV; +} + +try { + clearOidcEnvironment(); + const unconfigured = await freshModule('unconfigured'); + assert.equal(unconfigured.oidcMock, false); + await assert.rejects( + unconfigured.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_not_configured' && error.statusCode === 503, + ); + + process.env.SCOPEWEAVE_DEV = '1'; + const development = await freshModule('development'); + assert.equal(development.oidcMock, true); + await assert.rejects( + development.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_development_route_required', + ); + + configureProduction(); + delete process.env.OIDC_CLIENT_SECRET; + const incomplete = await freshModule('incomplete'); + await assert.rejects( + incomplete.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_configuration_incomplete', + ); + + configureProduction(); + process.env.OIDC_ISSUER = 'http://identity.example/tenant'; + const insecure = await freshModule('insecure'); + await assert.rejects( + insecure.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_issuer_invalid', + ); + + configureProduction(); + const configured = await freshModule('configured'); + let currentToken = signedIdToken(); + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url, init }); + if (url === `${ISSUER}/.well-known/openid-configuration`) { + return new Response(JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + id_token_signing_alg_values_supported: ['RS256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url === `${ISSUER}/token`) { + return new Response(JSON.stringify({ id_token: currentToken }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (url === `${ISSUER}/jwks`) { + return new Response(JSON.stringify({ keys: [PUBLIC_JWK] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error(`unexpected URL: ${url}`); + }; + + const authorization = await configured.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }); + const authorizationLocation = new URL(authorization.url); + assert.equal(authorization.redirectUri, REDIRECT_URI); + assert.equal(authorizationLocation.origin + authorizationLocation.pathname, `${ISSUER}/authorize`); + assert.equal(authorizationLocation.searchParams.get('response_type'), 'code'); + assert.equal(authorizationLocation.searchParams.get('scope'), 'openid email profile'); + assert.equal(authorizationLocation.searchParams.get('client_id'), CLIENT_ID); + assert.equal(authorizationLocation.searchParams.get('redirect_uri'), REDIRECT_URI); + assert.equal(authorizationLocation.searchParams.get('state'), STATE); + assert.equal(authorizationLocation.searchParams.get('nonce'), NONCE); + assert.equal(authorizationLocation.searchParams.get('code_challenge'), CHALLENGE); + assert.equal(authorizationLocation.searchParams.get('code_challenge_method'), 'S256'); + + const identity = await configured.exchangeAuthorizationCode({ + code: 'authorization-code-1', + codeVerifier: VERIFIER, + nonce: NONCE, + redirectUri: REDIRECT_URI, + nowSeconds: NOW_SECONDS, + }); + assert.equal(identity.email, 'owner@example.com'); + assert.equal(identity.subject, 'subject-42'); + const tokenCall = calls.find((call) => call.url === `${ISSUER}/token`); + assert.equal(tokenCall.init.method, 'POST'); + assert.match(tokenCall.init.headers.authorization, /^Basic /); + assert.ok(tokenCall.init.signal instanceof AbortSignal); + const tokenBody = new URLSearchParams(tokenCall.init.body); + assert.equal(tokenBody.get('grant_type'), 'authorization_code'); + assert.equal(tokenBody.get('code'), 'authorization-code-1'); + assert.equal(tokenBody.get('redirect_uri'), REDIRECT_URI); + assert.equal(tokenBody.get('code_verifier'), VERIFIER); + + assert.deepEqual( + configured.verifyIdToken({ + idToken: currentToken, + jwks: { keys: [PUBLIC_JWK] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }).email, + 'owner@example.com', + ); + + const invalidClaimCases = [ + ['issuer', { iss: 'https://attacker.example' }, 'oidc_id_token_issuer_invalid'], + ['audience', { aud: 'another-client' }, 'oidc_id_token_audience_invalid'], + ['authorized party', { aud: [CLIENT_ID, 'another-client'], azp: 'another-client' }, 'oidc_id_token_authorized_party_invalid'], + ['expiration', { exp: NOW_SECONDS - 61 }, 'oidc_id_token_expired'], + ['future issued-at', { iat: NOW_SECONDS + 61 }, 'oidc_id_token_issued_at_invalid'], + ['not-before', { nbf: NOW_SECONDS + 61 }, 'oidc_id_token_not_before_invalid'], + ['nonce', { nonce: 'nonce_attacker_abcdefghijklmnopqrstuvwxyz' }, 'oidc_id_token_nonce_invalid'], + ['subject', { sub: '' }, 'oidc_id_token_subject_invalid'], + ['email verification', { email_verified: false }, 'oidc_id_token_email_invalid'], + ]; + for (const [label, claims, code] of invalidClaimCases) { + assert.throws( + () => configured.verifyIdToken({ + idToken: signedIdToken({ claims }), + jwks: { keys: [PUBLIC_JWK] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === code, + label, + ); + } + + const { privateKey: attackerKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + assert.throws( + () => configured.verifyIdToken({ + idToken: signedIdToken({ signingKey: attackerKey }), + jwks: { keys: [PUBLIC_JWK] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === 'oidc_id_token_signature_invalid', + ); + assert.throws( + () => configured.verifyIdToken({ + idToken: signedIdToken({ header: { alg: 'none' } }), + jwks: { keys: [PUBLIC_JWK] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === 'oidc_id_token_algorithm_invalid', + ); + assert.throws( + () => configured.verifyIdToken({ + idToken: currentToken, + jwks: { keys: [] }, + issuer: ISSUER, + clientId: CLIENT_ID, + nonce: NONCE, + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === 'oidc_signing_key_not_found', + ); + + await assert.rejects( + configured.exchangeAuthorizationCode({ + code: 'authorization-code-1', + codeVerifier: VERIFIER, + nonce: NONCE, + redirectUri: 'https://scopeweave.example/incorrect', + nowSeconds: NOW_SECONDS, + }), + (error) => error.code === 'oidc_redirect_uri_mismatch', + ); + + configureProduction(); + const mismatch = await freshModule('issuer-mismatch'); + globalThis.fetch = async () => new Response(JSON.stringify({ + issuer: 'https://attacker.example', + authorization_endpoint: `${ISSUER}/authorize`, + token_endpoint: `${ISSUER}/token`, + jwks_uri: `${ISSUER}/jwks`, + }), { status: 200 }); + await assert.rejects( + mismatch.authorizationUrl({ + state: STATE, + nonce: NONCE, + codeChallenge: CHALLENGE, + }), + (error) => error.code === 'oidc_discovery_issuer_mismatch', + ); +} finally { + restoreEnvironment(); +} + +console.log('✓ OIDC production signature verification tests passed');