diff --git a/CHANGELOG.md b/CHANGELOG.md index b368e79..0ac8341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ ## 4.0.4 - 2026-06-dd ### Added +- Simplified `interact()` API: a single-call entry point that starts a + credential interaction from an opaque `https:` interaction URL, without the + caller composing a full `web` request. It always generates a request, + translating to the existing `get()` flow with the URL carried in the + `protocols` map under the well-known `interact` meta-protocol key. Reachable + on the `chapi` object returned by `load()`/`loadOnce()` and, when installing, + at `globalThis.chapi` (the polyfill never attaches to `navigator`). Resolves + to an empty object on completion; rejects with `AbortError` on user cancel or + `signal` abort. See `docs/specs/interact-api.md` and #50. +- `setGlobal` option on `load()`/`loadOnce()` (default `true`, preserving the + existing global-install behavior). When `false`, the polyfill attaches + nothing to the global environment and only returns the polyfill, letting + callers place the API (e.g., `globalThis.chapi`) themselves. +- Node.js unit tests (`node --test`) for the `interact()` functional core and + imperative shell, run via `npm run test:node` (and as part of `npm test`). - Cross-browser smoke test suite (Playwright) covering Chromium, Firefox, and WebKit. Verifies `loadOnce()` resolves and patches `navigator.credentials`, and includes a regression guard for the non-configurable diff --git a/README.md b/README.md index fa92ad0..392ba10 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,62 @@ if(!result) { } ``` +#### `interact()` + +> **Note:** `interact()` is a new, simplified entry point. See the +> [design spec](docs/specs/interact-api.md). The method name and return shape +> may still change. + +A coordinator website (a Relying Party such as an issuer or verifier) can start +a credential interaction by handing the polyfill a single **interaction URL**, +without composing a full `web` request object itself. `interact()` always +generates a credential _request_; under the hood it translates into the same +`navigator.credentials.get()` flow, carrying the URL in the `protocols` map +under the well-known `interact` key. + +`interact()` lives on the `chapi` object. The polyfill does **not** attach it to +`navigator`. By default (`setGlobal: true`), `load()`/`loadOnce()` set +`globalThis.chapi` for you, so you can just call `globalThis.chapi.interact(...)` +after loading. + +If you'd rather control where the API lives, pass `setGlobal: false` — the +polyfill then attaches nothing to the global environment and only returns the +polyfill, so you place `chapi` yourself: + +```js +try { + globalThis.chapi = (await loadOnce({setGlobal: false})).chapi; +} catch(e) { + console.log('CHAPI failed to load.'); +} +``` + +Then call it: + +```js +await chapi.interact({ + // required: an `https:` URL the coordinator already trusts; the polyfill + // treats it as opaque (it does not fetch, parse, or encode it). The full + // "protocols" object is fetched from this URL over TLS by the selected + // handler, which authenticates its source and supports disconnected + // systems (e.g. QR-code readers). + interactionUrl: 'https://coordinator.example/exchanges/z1A2b3C4', + // optional: an AbortSignal to cancel the interaction + signal, + // optional: credential handler origins to recommend to the user + recommendedHandlerOrigins: ['https://wallet.example.chapi.io'] +}); +``` + +The returned promise: + +- **resolves** to an empty object (`{}`) when the interaction completes; no + credential data is returned to the coordinator (data minimization), +- **rejects** with a `DOMException` named `AbortError` when the user cancels or + the caller aborts via `signal`, +- otherwise rejects with the same errors surfaced by + [`get()`](#get) (e.g. `SecurityError` outside a secure context). + #### WebCredential TODO: Discuss creating and receiving WebCredential instances diff --git a/docs/specs/interact-api.md b/docs/specs/interact-api.md new file mode 100644 index 0000000..498576b --- /dev/null +++ b/docs/specs/interact-api.md @@ -0,0 +1,180 @@ +# Spec: Simplified `interact()` API for interaction URLs +> Status: In review — an initial implementation has landed in this PR; some open questions below remain to be settled during review (final method name, resolution payload, in-flight `signal` teardown). Addresses [#50](https://github.com/credential-handler/credential-handler-polyfill/issues/50). +## Summary +Add a drastically simplified, single-call CHAPI entry point that lets issuer/verifier coordinator websites (relying parties) start a credential interaction by handing the polyfill an `interactionUrl`, without composing the full `web`/`VerifiablePresentation` request object themselves. The new `interact({interactionUrl, signal, recommendedHandlerOrigins})` method always generates a credential _request_ and resolves when the interaction completes (to an empty object/`undefined` for now) or rejects with an `AbortError` when the user cancels or the caller aborts via an `AbortSignal`. Under the hood it translates into the existing `credentialrequest` (`get()`) flow — reusing the established `protocols` mechanism to carry the URL — so it is compatible with today's mediator and deployed credential handlers. The method is reachable on the object returned by `load()`/`loadOnce()` and, when installing, at the global `globalThis.chapi`; the polyfill never attaches to `navigator` (addressing the "avoid `navigator`" request in the issue thread). A `setGlobal: false` option lets callers suppress all global attachment and place the API wherever they like. +## Implementation details & assumptions +### New public API +```js +// Shape (param names subject to bikeshedding per the issue): +await chapi.interact({ + interactionUrl, // required string (https: URL) + signal, // optional AbortSignal + recommendedHandlerOrigins // optional string[] +}); +``` + +Resolution contract: + +- **Resolves** to an empty object (or `undefined`) when the interaction completes for now. The return shape is intentionally minimal and reserved for future expansion; callers must not depend on a credential being returned yet. + +- **Rejects** with a `DOMException` of name `AbortError` when the user cancels in the mediator UI, or when the caller aborts via `signal`. + +- Other failures reject with the existing error types surfaced by `get()` (e.g. `NotSupportedError`, `SecurityError` from the secure-context assertion). + +### Two ways to reach it (issue asks for both) +The polyfill deliberately **never** attaches `chapi` to `navigator` — the +thread flags `navigator` as prone to clobbering by password-manager extensions +and browser security changes. Instead: + +1. **Returned object** — `load()`/`loadOnce()` return the polyfill with a + `chapi` property (containing `interact`), so callers can wire it up however + they like. The recommended pattern attaches it to `globalThis` explicitly: + + ```js + try { + globalThis.chapi = (await loadOnce()).chapi; + } catch(e) { + console.log('CHAPI failed to load.'); + } + ``` + +2. **`setGlobal` option** — `load({setGlobal})`/`loadOnce({setGlobal})` + controls automatic global installation. `setGlobal: true` (the default, for + backwards compatibility) installs the polyfill globally as today **and** sets + `globalThis.chapi = chapi`. `setGlobal: false` attaches nothing to the global + environment and only returns the polyfill, leaving placement entirely to the + caller. + + Both paths share one implementation; `globalThis.chapi` (when installed) is + just the same `chapi` object that is also returned. + +### Translation to existing flow +- `interact()` always translates to a `navigator.credentials.get()` call with a `web` request. There is no `type` parameter: generating a request is expected to cover all current use cases (per review feedback on the draft PR), and a `store`-style flow can be added later without changing this signature if a concrete need emerges. + +- `interactionUrl` is carried via the **existing** `protocols` **map** (the query-param mechanism already used for URL-type credential handlers), not a new mediator field, under the well-known key **`interact`**: + + ```js + web: { + protocols: {interact: interactionUrl} + } + ``` + + `interact` is a "meta" protocol: any underlying exchange protocol (e.g. `vcapi`, `OID4VCI`) is negotiated at the interaction endpoint and stays hidden behind the URL, so CHAPI never needs to know or carry it. Where `get()`/`store()` historically accepted a multi-key `protocols` object, `interact()` always sends a **single-key** object — just `interact` — which is expected to supersede the multi-key form going forward (see Security considerations for why the URL indirection is preferred). The polyfill treats `interactionUrl` as an **opaque** string — it does not parse, encode, or decode it (any obfuscation such as base64url-encoding is the caller's concern). This keeps the change client-side only — no mediator or RPC contract changes required for the initial release. + +- `recommendedHandlerOrigins` passes straight through to the underlying request options, identical to current `get()`/`store()` semantics. + +- `signal` is wired to the abort path: if already aborted, reject immediately; otherwise reject with `AbortError` when it fires. + +### Functional-core / imperative-shell split +Per house practice, the request-construction logic is a **pure function** — `(interactionUrl, recommendedHandlerOrigins) → CredentialRequestOptions` — independently testable with no mediator, no `navigator`, no network. The imperative shell (`interact()`) does the secure-context check, awaits the RPC, and maps the result/abort to the resolution contract. +### Assumptions +- The deployed mediator and at least one URL-type credential handler already honor the `protocols` query-param mechanism (it is documented and shipped). + +- `interactionUrl` is an `https:` URL the coordinator already trusts; the polyfill validates the scheme but does not fetch or interpret it. + +- No top-level-await requirement is introduced beyond what `loadOnce()` already implies. + +## Data flows +``` +Coordinator page + → chapi.interact({interactionUrl, ...}) + → pure builder → CredentialRequestOptions (interactionUrl placed in + protocols under the `interact` key) + → CredentialsContainer.get(options) [existing RPC] + → web-request-rpc → Credential Mediator (authn.io) in cross-origin iframe + → mediator → user selects handler / handler receives interactionUrl as + query param on its registered URL + ← resolve (empty) on completion | reject AbortError on cancel/abort +``` + +Trust boundaries (unchanged from existing `get()`): + +- The coordinator page cannot enumerate the user's wallets/handlers (anti- fingerprinting); `recommendedHandlerOrigins` are _suggestions_ surfaced by the mediator UI, not a query of installed handlers. + +- The mediator runs cross-origin and mediates all handler communication. + +- `interactionUrl` crosses to the selected handler only after user selection. + +## DB schema changes +None. This is a browser polyfill with no datastore. +## API endpoints and scope +No new server endpoints. Surface area is the client API only: + +- New method `interact()` on the `chapi` object. + +- New `chapi` property on the object returned by `load()`/`loadOnce()`. + +- New `globalThis.chapi` global set during `load()`/`loadOnce()` when + `setGlobal` is `true` (the default). + +- New `setGlobal` option on `load()`/`loadOnce()` (default `true`) that, when + `false`, suppresses all global installation. + + +No existing public methods change behavior; this is purely additive. The new +`setGlobal` option defaults to `true`, preserving the current global-install +behavior of `load()`/`loadOnce()` (no breaking change to `get()`, `store()`, +`load()`, or `loadOnce()`). +## Personal information impact +The polyfill itself collects, stores, and persists **no** personal data. It is a message broker between the coordinator page and a cross-origin mediator. + +- **Categories touched (in transit only):** `interactionUrl` and `recommendedHandlerOrigins` — neither is personal data by itself; they are endpoint/origin references chosen by the coordinator. Any personal data exchanged (e.g. a Verifiable Presentation) flows between the user-selected handler and the interaction endpoint, **not** through new code added here, and is not returned to the coordinator by `interact()` in this release. + +- **Purpose:** initiate a user-consented credential interaction. + +- **Storage:** none added. No new persistence, cookies, or localStorage. + +- **Transmission:** over the existing cross-origin RPC channel; the secure- context assertion (`window.isSecureContext`) is retained, so HTTPS/TLS is required. + +- **Data minimization:** `interact()` deliberately returns an empty result for now, returning _less_ to the coordinator than `get()` does, which reduces the data exposed to the relying party. + +## Security considerations +- **How could this be misused?** A malicious coordinator could pass a hostile `interactionUrl`. Mitigation: the polyfill validates the scheme is `https:`, does not fetch or execute the URL, and the URL only reaches a credential handler _after explicit user selection_ in the trusted mediator UI. User consent remains the gate, unchanged from `get()`. + +- **Attack surface / unnecessary data:** additive method reusing the existing RPC path; no new cross-origin channel, and no new global beyond `globalThis.chapi` (which `setGlobal: false` suppresses entirely). The empty-result contract avoids handing credential data to the relying party. + +- **Trusted vs untrusted sources:** `interactionUrl` and `recommendedHandlerOrigins` are **untrusted** caller input — validated (scheme, type) before use, never interpolated into executable contexts. Results from the mediator are treated as today (validation TODOs in `CredentialsContainer` apply equally). + +- **Anti-fingerprinting:** preserved — `interact()` gives the caller no way to learn which handlers the user has, consistent with the existing privacy model. + +- `navigator` **clobbering:** the polyfill never attaches `chapi` to `navigator`, and `setGlobal: false` lets security-conscious callers suppress all automatic global attachment and place the API themselves, reducing exposure to extension/browser interference noted in the issue. + +- **Why a URL instead of an inline `protocols` object (design rationale):** the single `interact` URL is a layer of indirection over the "full" `protocols` object. Rather than embedding a multi-key protocols object directly in the initial channel (e.g. a QR code), the relying party hands over one URL; the consuming side fetches the full protocols object from it **over TLS**. This yields two properties an inline blob cannot: (1) **source authentication** — TLS authenticates the origin of the protocols object, so the recipient can verify who issued it; and (2) **support for disconnected systems** — a reader with no back-channel (e.g. a QR-code scanner) can still authenticate the source by reusing existing TLS infrastructure, without new key-distribution or crypto. The expectation is that the single-key `interact` object supersedes multi-key `protocols` objects going forward. + +## Open questions + +These remain **open by design** and are to be resolved during the initial +review on the draft PR — they are not blockers to opening that draft. + +1. **Final names.** `interact` and the factory export name are flagged for + bikeshedding in the issue. Names above are placeholders. +2. **Resolution payload.** Confirm `interact()` resolves to `{}` vs `undefined`. + *Current implementation resolves to `{}`.* Open: whether a future version + returns interaction results (and if so, what the minimal shape is). +3. **Performance for first-time calls.** The issue notes a no-`loadOnce()` path + may incur first-call setup cost (injecting the mediator iframe/styles). Do we + need a documented "warm-up" call, or is lazy initialization on first + `interact()` acceptable? +4. **`signal` interaction with the RPC.** *Current implementation* races the + in-flight `get()` against the signal and rejects with `AbortError` when the + signal fires, but it **abandons** the underlying RPC rather than tearing it + down (`get()` uses an indefinite timeout). Open: confirm whether abandoning + is acceptable or whether the RPC must be actively cancelled to avoid a leak. +5. **User-cancel mapping.** *Current implementation* treats `get()` resolving + `null` (no credential selected) as a user cancel and rejects with + `AbortError`. Open: confirm this is the desired contract vs. resolving empty + on a non-selection. + +## Resolved during review + +- **`interactionUrl` mapping key** → use the well-known `protocols` key + **`interact`** (a "meta" protocol), with `interactionUrl` as the value. Any + underlying exchange protocol stays hidden behind the URL, so no `protocol` + param is needed on `interact()`. *Caveat:* a URL-type credential handler only + receives this value if it advertised `acceptedProtocols: ["interact"]` in its + manifest — deployed handlers may need that entry to participate. +- **`type` param** → dropped; `interact()` always generates a request. + +--- + +_Next step: distribute this spec to Engineering, DevOps, CTO, and the Privacy Officer for review before any implementation begins._ diff --git a/lib/index.js b/lib/index.js index eb0662d..8a3e188 100644 --- a/lib/index.js +++ b/lib/index.js @@ -3,6 +3,7 @@ */ import {WebAppContext} from 'web-request-rpc'; +import {_createInteract} from './interact.js'; import {CredentialHandler} from './CredentialHandler.js'; import {CredentialHandlers} from './CredentialHandlers.js'; import {CredentialManager} from './CredentialManager.js'; @@ -30,21 +31,36 @@ export async function loadOnce(options) { } export async function load(options = { + setGlobal: true, mediatorOrigin: DEFAULT_MEDIATOR_ORIGIN }) { _assertSecureContext(); - // backwards compatibility (`options` used to be a string for expressing - // the full mediator URL) + let mediatorUrl; + // when `true` (the default), the polyfill installs itself on the global + // environment (`navigator.credentials`, `navigator.credentialsPolyfill`, + // and `globalThis.chapi`); when `false`, it attaches nothing and only + // returns the polyfill so callers can wire it up however they like + let setGlobal = true; if(typeof options === 'string') { + // backwards compatibility (`options` used to be a string expressing the + // full mediator URL) mediatorUrl = options; - } else if(options && typeof options === 'object' && - typeof options.mediatorOrigin === 'string') { - mediatorUrl = `${options.mediatorOrigin}/mediator`; } else { - throw new Error( - '"options.mediatorOrigin" must be a string expressing the ' + - 'origin of the mediator.'); + // otherwise `options` must be an object; validate each option + // independently + if(!(options && typeof options === 'object')) { + throw new Error('"options" must be an object or a string.'); + } + if(typeof options.mediatorOrigin !== 'string') { + throw new Error( + '"options.mediatorOrigin" must be a string expressing the ' + + 'origin of the mediator.'); + } + mediatorUrl = `${options.mediatorOrigin}/mediator`; + if(options.setGlobal !== undefined) { + setGlobal = options.setGlobal; + } } // temporarily still using this for setting permissions and other @@ -75,8 +91,23 @@ export async function load(options = { polyfill.WebCredential = WebCredential; + // the simplified `interact()` API, bound to this polyfill's credentials + // container; always available on the returned polyfill (`polyfill.chapi`) + // and, when installing, recommended as the global `globalThis.chapi` so + // callers can use either without composing a full request object themselves + const chapi = { + interact: _createInteract({credentials: polyfill.credentials}) + }; + polyfill.chapi = chapi; + + if(!setGlobal) { + // do not attach anything globally; just return the polyfill + return polyfill; + } + // expose polyfill navigator.credentialsPolyfill = polyfill; + globalThis.chapi = chapi; // polyfill if('credentials' in navigator) { diff --git a/lib/interact.js b/lib/interact.js new file mode 100644 index 0000000..f85c5e6 --- /dev/null +++ b/lib/interact.js @@ -0,0 +1,104 @@ +/*! + * The `interact()` API: a simplified, single-call CHAPI entry point that + * starts a credential interaction from an interaction URL. The imperative + * shell here wires the pure request builder to the existing `get()` RPC and + * maps the result/abort to the resolution contract. See + * docs/specs/interact-api.md. + * + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {createInteractRequest} from './interactRequest.js'; + +/** + * Asserts the current context is secure (HTTPS/TLS), matching the existing + * polyfill behavior. + */ +export function assertSecureContext() { + if(!globalThis.isSecureContext) { + throw new DOMException('The operation is insecure.', 'SecurityError'); + } +} + +/** + * Builds an `interact()` function bound to a given credentials container. + * Exported (with a leading underscore) for testing the imperative shell + * without a browser; production code reaches `interact()` via the chapi + * object returned from `load()`/`loadOnce()` or the standalone factory. + * + * @param {object} options - The options to use. + * @param {object} options.credentials - A credentials container exposing + * `get(CredentialRequestOptions)` (the RPC seam). + * @param {Function} [options.assertSecureContext] - Secure-context assertion; + * defaults to the real check. + * + * @returns {Function} The `interact()` function. + */ +export function _createInteract({ + credentials, assertSecureContext: assertSecure = assertSecureContext +} = {}) { + /** + * Starts a credential interaction. + * + * @param {object} options - The options to use. + * @param {string} options.interactionUrl - An `https:` interaction URL. + * @param {AbortSignal} [options.signal] - Aborts the interaction. + * @param {string[]} [options.recommendedHandlerOrigins] - Optional handler + * origins to recommend to the user. + * + * @returns {Promise} Resolves to an empty object on completion; + * rejects with an `AbortError` on cancel/abort. + */ + return async function interact({ + interactionUrl, signal, recommendedHandlerOrigins + } = {}) { + assertSecure(); + + // build the request before touching the signal so invalid input fails + // fast with a synchronous validation error + const request = createInteractRequest({ + interactionUrl, recommendedHandlerOrigins + }); + + // reject immediately if already aborted; surfaces the signal's own + // `reason` (a `DOMException` named `AbortError` by default) + signal?.throwIfAborted(); + + // race the in-flight RPC against the abort signal so an abort rejects + // promptly rather than waiting on the indefinite-timeout `get()` + const credential = await _withAbort(credentials.get(request), signal); + + if(!credential) { + // no credential selected: treat as a user cancel + throw _abortError(); + } + + // minimal resolution contract: resolve to an empty object for now; the + // shape is reserved for future expansion and intentionally returns no + // credential data to the relying party + return {}; + }; +} + +function _abortError() { + return new DOMException('The operation was aborted.', 'AbortError'); +} + +/** + * Rejects as soon as `signal` fires, otherwise settles with `promise`. + * + * @param {Promise} promise - The in-flight operation. + * @param {AbortSignal} [signal] - Optional abort signal. + * + * @returns {Promise} The race result. + */ +function _withAbort(promise, signal) { + if(!signal) { + return promise; + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(_abortError()); + signal.addEventListener('abort', onAbort, {once: true}); + promise.then(resolve, reject).finally( + () => signal.removeEventListener('abort', onAbort)); + }); +} diff --git a/lib/interactRequest.js b/lib/interactRequest.js new file mode 100644 index 0000000..7bad7b2 --- /dev/null +++ b/lib/interactRequest.js @@ -0,0 +1,57 @@ +/*! + * Pure request-construction for the `interact()` API. Builds a + * `CredentialRequestOptions` for `navigator.credentials.get()` from an + * interaction URL, with no mediator, no `navigator`, and no network -- so it + * is independently testable. See docs/specs/interact-api.md. + * + * Copyright (c) 2026 Digital Bazaar, Inc. + */ + +/** + * Builds the `get()` request that carries an interaction URL. + * + * The URL is placed in the `protocols` map under the well-known `interact` + * key (a "meta" protocol); the polyfill treats it as opaque and does not + * parse, encode, or decode it. Any underlying exchange protocol stays hidden + * behind the URL. + * + * @param {object} options - The options to use. + * @param {string} options.interactionUrl - An `https:` interaction URL. + * @param {string[]} [options.recommendedHandlerOrigins] - Optional handler + * origins to recommend to the user. + * + * @returns {object} A `CredentialRequestOptions` for `credentials.get()`. + */ +export function createInteractRequest({ + interactionUrl, recommendedHandlerOrigins +} = {}) { + if(typeof interactionUrl !== 'string') { + throw new TypeError('"interactionUrl" must be a string.'); + } + // validate the scheme only; the value is otherwise treated as opaque and + // stored byte-for-byte (the original string, not a re-serialized URL) + let parsed; + try { + parsed = new URL(interactionUrl); + } catch(cause) { + const error = new Error( + '"interactionUrl" string must be a valid "https:" URL.'); + error.cause = cause; + throw error; + } + if(parsed.protocol !== 'https:') { + throw new Error('"interactionUrl" protocol must be "https:".'); + } + if(recommendedHandlerOrigins !== undefined && + !(Array.isArray(recommendedHandlerOrigins) && + recommendedHandlerOrigins.every(s => URL.parse(s) !== null))) { + throw new TypeError( + '"recommendedHandlerOrigins" must be an array of URL strings.'); + } + + const web = {protocols: {interact: interactionUrl}}; + if(recommendedHandlerOrigins !== undefined) { + web.recommendedHandlerOrigins = recommendedHandlerOrigins; + } + return {web}; +} diff --git a/package.json b/package.json index 45f517b..7281a2b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "prepublish": "npm run build", "build": "webpack", "lint": "eslint --no-warn-ignored .", - "test": "playwright test" + "test": "npm run test:node && npm run test:browser", + "test:node": "node --test 'test/node/**/*.test.js'", + "test:browser": "playwright test" }, "repository": { "type": "git", diff --git a/playwright.config.js b/playwright.config.js index e18bb61..95910bb 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -10,6 +10,9 @@ const PORT = 9876; export default defineConfig({ testDir: './test', + // node:test unit tests for the pure functional core live in test/node/ and + // are run via `npm run test:node`, not Playwright + testIgnore: '**/node/**', fullyParallel: true, forbidOnly: !!process.env.CI, retries: 0, diff --git a/test/interact.spec.js b/test/interact.spec.js new file mode 100644 index 0000000..40a566e --- /dev/null +++ b/test/interact.spec.js @@ -0,0 +1,92 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {expect, test} from '@playwright/test'; + +// Smoke test for the simplified `interact()` API wiring (PR #57). Verifies +// that loading exposes `interact` on the returned polyfill object and, when +// installing, at `globalThis.chapi`; that `setGlobal: false` attaches nothing; +// and that input validation reaches through the built bundle. The pure +// builder and shell logic are covered exhaustively by the node:test unit +// tests in test/node/; this only checks the wiring. + +test('loadOnce() exposes interact() on the polyfill and globalThis.chapi', + async ({page}) => { + await page.goto('/test/fixtures/index.html'); + + const result = await page.evaluate(async () => { + const polyfill = await window.credentialHandlerPolyfill.loadOnce(); + return { + returnedInteractIsFn: typeof polyfill.chapi?.interact === 'function', + globalInteractIsFn: typeof globalThis.chapi?.interact === 'function', + // both paths share one implementation + sameChapi: polyfill.chapi === globalThis.chapi + }; + }); + + expect(result.returnedInteractIsFn).toBe(true); + expect(result.globalInteractIsFn).toBe(true); + expect(result.sameChapi).toBe(true); + }); + +test('load({setGlobal: false}) attaches nothing globally', async ({page}) => { + await page.goto('/test/fixtures/index.html'); + + const result = await page.evaluate(async () => { + const polyfill = await window.credentialHandlerPolyfill.load({ + mediatorOrigin: 'https://authn.io', + setGlobal: false + }); + return { + returnedInteractIsFn: typeof polyfill.chapi?.interact === 'function', + // nothing should have been attached to the global environment + noGlobalChapi: globalThis.chapi === undefined, + noPolyfillGlobal: navigator.credentialsPolyfill === undefined + }; + }); + + expect(result.returnedInteractIsFn).toBe(true); + expect(result.noGlobalChapi).toBe(true); + expect(result.noPolyfillGlobal).toBe(true); +}); + +test('interact() rejects a non-https interactionUrl through the bundle', + async ({page}) => { + await page.goto('/test/fixtures/index.html'); + + const error = await page.evaluate(async () => { + await window.credentialHandlerPolyfill.loadOnce(); + try { + await globalThis.chapi.interact({ + interactionUrl: 'http://insecure.example/abc' + }); + return null; + } catch(e) { + return e.message; + } + }); + + expect(error).toContain('https:'); + }); + +test('interact() rejects immediately when signal is already aborted', + async ({page}) => { + await page.goto('/test/fixtures/index.html'); + + const name = await page.evaluate(async () => { + await window.credentialHandlerPolyfill.loadOnce(); + const controller = new AbortController(); + controller.abort(); + try { + await globalThis.chapi.interact({ + interactionUrl: 'https://exchange.example/abc', + signal: controller.signal + }); + return null; + } catch(e) { + return e.name; + } + }); + + expect(name).toBe('AbortError'); + }); diff --git a/test/node/10-interactRequest.test.js b/test/node/10-interactRequest.test.js new file mode 100644 index 0000000..ffc6cdd --- /dev/null +++ b/test/node/10-interactRequest.test.js @@ -0,0 +1,100 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {strict as assert} from 'node:assert'; +import {createInteractRequest} from '../../lib/interactRequest.js'; +import test from 'node:test'; + +// The pure functional core: it builds a `CredentialRequestOptions` for +// `navigator.credentials.get()` from an interaction URL, with no mediator, +// no `navigator`, and no network. See docs/specs/interact-api.md. + +test('places interactionUrl under the `interact` protocols key', () => { + const options = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc' + }); + assert.deepEqual(options, { + web: { + protocols: {interact: 'https://exchange.example/abc'} + } + }); +}); + +test('includes recommendedHandlerOrigins when provided', () => { + const options = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc', + recommendedHandlerOrigins: ['https://wallet.example'] + }); + assert.deepEqual(options, { + web: { + protocols: {interact: 'https://exchange.example/abc'}, + recommendedHandlerOrigins: ['https://wallet.example'] + } + }); +}); + +test('omits recommendedHandlerOrigins when not provided', () => { + const options = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc' + }); + assert.equal('recommendedHandlerOrigins' in options.web, false); +}); + +test('produces only the single-key `interact` protocols object', () => { + const {protocols} = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc' + }).web; + assert.deepEqual(Object.keys(protocols), ['interact']); +}); + +test('treats interactionUrl as opaque (does not parse or re-encode)', () => { + // a URL with query/fragment must pass through byte-for-byte + const url = 'https://exchange.example/abc?x=1&y=2#frag'; + const options = createInteractRequest({interactionUrl: url}); + assert.equal(options.web.protocols.interact, url); +}); + +test('throws TypeError when interactionUrl is missing', () => { + assert.throws(() => createInteractRequest({}), TypeError); +}); + +test('throws TypeError when interactionUrl is not a string', () => { + assert.throws( + () => createInteractRequest({interactionUrl: 42}), TypeError); +}); + +test('throws a protocol error when interactionUrl is not https:', () => { + assert.throws( + () => createInteractRequest({interactionUrl: 'http://exchange.example'}), + /protocol must be "https:"/); +}); + +test('throws a parse error with a cause for a malformed URL', () => { + assert.throws( + () => createInteractRequest({interactionUrl: 'not a url'}), + e => /must be a valid "https:" URL/.test(e.message) && + e.cause !== undefined); +}); + +test('throws when recommendedHandlerOrigins is not an array', () => { + assert.throws(() => createInteractRequest({ + interactionUrl: 'https://exchange.example/abc', + recommendedHandlerOrigins: 'https://wallet.example' + }), TypeError); +}); + +test('throws when recommendedHandlerOrigins has a non-URL entry', () => { + assert.throws(() => createInteractRequest({ + interactionUrl: 'https://exchange.example/abc', + recommendedHandlerOrigins: ['https://wallet.example', 'not a url'] + }), /array of URL strings/); +}); + +test('accepts an array of valid URL-string origins', () => { + const options = createInteractRequest({ + interactionUrl: 'https://exchange.example/abc', + recommendedHandlerOrigins: ['https://wallet.example'] + }); + assert.deepEqual( + options.web.recommendedHandlerOrigins, ['https://wallet.example']); +}); diff --git a/test/node/20-interact.test.js b/test/node/20-interact.test.js new file mode 100644 index 0000000..0dd4e8b --- /dev/null +++ b/test/node/20-interact.test.js @@ -0,0 +1,145 @@ +/*! + * Copyright (c) 2026 Digital Bazaar, Inc. + */ +import {_createInteract} from '../../lib/interact.js'; +import {strict as assert} from 'node:assert'; +import test from 'node:test'; + +// The imperative shell. It is built around an injected `credentials` +// container (the RPC seam) and an injected `assertSecureContext` so it can be +// exercised without a browser. See docs/specs/interact-api.md. + +const URL_OK = 'https://exchange.example/abc'; + +function makeCredentials(impl) { + return {get: impl}; +} + +// a secure-context assertion that passes, for the happy paths +const secure = () => {}; + +test('translates to credentials.get() with the interact request', async () => { + let received; + const interact = _createInteract({ + credentials: makeCredentials(async options => { + received = options; + return {selected: true}; + }), + assertSecureContext: secure + }); + await interact({interactionUrl: URL_OK}); + assert.deepEqual(received, { + web: {protocols: {interact: URL_OK}} + }); +}); + +test('passes recommendedHandlerOrigins through to get()', async () => { + let received; + const interact = _createInteract({ + credentials: makeCredentials(async options => { + received = options; + return {selected: true}; + }), + assertSecureContext: secure + }); + await interact({ + interactionUrl: URL_OK, + recommendedHandlerOrigins: ['https://wallet.example'] + }); + assert.deepEqual( + received.web.recommendedHandlerOrigins, ['https://wallet.example']); +}); + +test('resolves to an empty object on completion', async () => { + const interact = _createInteract({ + credentials: makeCredentials(async () => ({some: 'credential'})), + assertSecureContext: secure + }); + const result = await interact({interactionUrl: URL_OK}); + // minimal contract: resolves to {} regardless of what get() returned + assert.deepEqual(result, {}); +}); + +test('rejects with AbortError when signal is already aborted', async () => { + let called = false; + const interact = _createInteract({ + credentials: makeCredentials(async () => { + called = true; + return null; + }), + assertSecureContext: secure + }); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + () => interact({interactionUrl: URL_OK, signal: controller.signal}), + e => e.name === 'AbortError'); + // must not even call get() if already aborted + assert.equal(called, false); +}); + +test('rejects with AbortError when signal fires mid-flight', async () => { + const controller = new AbortController(); + const interact = _createInteract({ + // a get() that never settles on its own + credentials: makeCredentials(() => new Promise(() => {})), + assertSecureContext: secure + }); + const promise = interact({ + interactionUrl: URL_OK, signal: controller.signal + }); + controller.abort(); + await assert.rejects(promise, e => e.name === 'AbortError'); +}); + +test('rejects with AbortError when get() resolves null', async () => { + // when no credential is selected, get() resolves null; interact() treats + // that as a user cancel and rejects with AbortError + const interact = _createInteract({ + credentials: makeCredentials(async () => null), + assertSecureContext: secure + }); + await assert.rejects( + () => interact({interactionUrl: URL_OK}), + e => e.name === 'AbortError'); +}); + +test('propagates other get() errors unchanged', async () => { + const err = new DOMException('Not implemented.', 'NotSupportedError'); + const interact = _createInteract({ + credentials: makeCredentials(async () => { + throw err; + }), + assertSecureContext: secure + }); + await assert.rejects( + () => interact({interactionUrl: URL_OK}), + e => e.name === 'NotSupportedError'); +}); + +test('asserts secure context before doing anything', async () => { + let getCalled = false; + const interact = _createInteract({ + credentials: makeCredentials(async () => { + getCalled = true; + return null; + }), + assertSecureContext: () => { + throw new DOMException('The operation is insecure.', 'SecurityError'); + } + }); + await assert.rejects( + () => interact({interactionUrl: URL_OK}), + e => e.name === 'SecurityError'); + assert.equal(getCalled, false); +}); + +test('rejects invalid interactionUrl via the builder', async () => { + const interact = _createInteract({ + credentials: makeCredentials(async () => null), + assertSecureContext: secure + }); + await assert.rejects( + () => interact({interactionUrl: 'http://insecure.example'}), + /https:/); +}); diff --git a/test/smoke.spec.js b/test/smoke.spec.js index 555b7ca..b0412c8 100644 --- a/test/smoke.spec.js +++ b/test/smoke.spec.js @@ -22,7 +22,8 @@ test('loadOnce() resolves and patches navigator.credentials', async ({ return { hasWebCredential: typeof window.WebCredential === 'function', getIsFn: typeof navigator.credentials.get === 'function', - storeIsFn: typeof navigator.credentials.store === 'function' + storeIsFn: typeof navigator.credentials.store === 'function', + interactIsFn: typeof globalThis.chapi?.interact === 'function' }; }); @@ -31,6 +32,8 @@ test('loadOnce() resolves and patches navigator.credentials', async ({ expect(result.hasWebCredential).toBe(true); expect(result.getIsFn).toBe(true); expect(result.storeIsFn).toBe(true); + // the simplified interact() API is installed at globalThis.chapi by load() + expect(result.interactIsFn).toBe(true); }); test('loadOnce() resolves when navigator.credentials is non-configurable',