DEV Community

FitzgeraldBlake3561
FitzgeraldBlake3561

Posted on

Implementing Node.js Health Consent Checks: Category Grants Beyond Password Recovery

Short answer: in a health-services marketplace, password recovery may restore account access, but it must never create, widen, or silently revive health data consent. Treat the reset as a short-lived, single-use authorization; keep category checks and consent grants behind a separate policy boundary; and record revocation decisions so an auditor can reconstruct both paths.

That separation is the architecture decision. A buyer who forgot a password still owns the account, yet possession of an email link doesn't prove permission to change a caregiver's access to lab results. The same warning applies to a provider account with access to many patients. Recovery answers one narrow question: may this principal establish a new authenticator? Health data consent answers another: may this actor use this category for this purpose?

Don't merge them.

Recovery is not consent.

OWASP recommends consistent responses and timing for existing and nonexistent accounts, side-channel delivery of reset tokens, random and sufficiently long tokens, secure storage, expiration, and single use. It also recommends rate limiting without locking the account. Those controls form the recovery perimeter. They don't replace category-level authorization after login.

What invariants should health data consent category checks enforce during recovery?

Write the invariants before choosing storage or an identity service. The first is deliberately restrictive: a recovery transaction can change authentication state and nothing else. It cannot add clinical_notes, expand a purpose from care_delivery to marketplace_analytics, switch the authorized recipient, accept new terms, or clear a prior consent revocation. A successful reset should also invalidate its own grant immediately and should not automatically start an application session.

The second invariant is non-enumeration. The public response for an unknown address should match the response for a known address, and the work performed should be close enough that response timing doesn't become an account directory. Internally, the two cases remain distinguishable for audit and abuse analysis. This is an awkward logging split — generic outside, specific inside — but it avoids making support logs useless while protecting the marketplace's member list.

The third invariant is that every protected read still presents a complete decision tuple after recovery: subject, actor, category, purpose, and active consent revision. Authentication is evidence about the actor. It isn't evidence that patient-104 granted provider-28 access to medications for care_delivery.

These invariants create clear failure boundaries. The recovery service owns request normalization, throttling, token issuance, token consumption, and authenticator replacement. The consent service owns category and purpose evaluation. A worker, export, support console, or API handler that reads protected data must call the same authorization interface; none may infer permission from password_reset_at, a fresh session, or account ownership alone.

How can abuse tests prove category grants and revocation?

Test the authority boundary before writing the handler. Attempt to read lab_results using a valid reset token but no authenticated session. Deny it. Establish the new authenticator and try again with a session but no matching category grant. Deny it again. Finally, add an active grant for the exact subject, actor, category, and purpose; only that request should pass. Now revoke revision 7, repeat the read, and require a denial even though the password reset remains recent. This sequence is more useful than a happy-path reset test because it proves that neither a token nor a fresh credential is being treated as blanket health data consent, and it gives reviewers a concrete failure boundary to preserve during later refactors.

The design decision isn't “database or vendor.” It is where authority lives and how much damage a recovered credential can do. This table records the options against that test.

Option Abuse boundary Audit quality When it fits
Reset flag on the user row Recovery and consent state can be coupled by application code Current state is easy to inspect; transitions need separate records A prototype with no health data and no delegated access
Separate recovery grant and consent decision A reset token authorizes only authenticator replacement Each decision can retain its own reason, revision, and timestamp A marketplace with patient, provider, and support roles
Central policy service Many consumers share one category-check contract Policy versions and decisions can be correlated across services Multiple services that can support another runtime dependency

The middle option is the default here. It limits the blast radius without making every request depend on a new network hop. A Node.js API can own the HTTP boundary and persist both records in its database, while enforcing separate modules and tables. The implementation language doesn't create the security boundary; the data model and allowed state transitions do.

That distinction has teeth.

There is a catch. A local decision module is not suitable when batch jobs, partner integrations, and support tools each implement their own interpretation of consent. Choose a shared policy service in that case, accepting deployment, availability, and policy-version coordination as explicit operational costs. Conversely, stick with a local transaction when there is one service and one database. Distribution adds work before it adds control.

I'm not sure a fixed reset lifetime is right for every marketplace. Risk, mail delivery, and support practices differ. What can be decided without guesswork is the invariant: the configured deadline must be enforced server-side, captured in the audit event, and tested at the boundary rather than trusted to a countdown in the browser.

Implement the critical path as two authorization decisions

The following Python is a runnable model of the state machine a Node.js handler can implement. All identifiers and values are example data, not a route contract. The code deliberately emits stable public outcomes while retaining specific internal reasons, consumes a reset grant once, and refuses to let authentication recovery mutate consent.

from dataclasses import dataclass, replace
from datetime import datetime, timedelta, timezone
from hashlib import sha256
from hmac import compare_digest


@dataclass(frozen=True)
class RecoveryGrant:
    account_id: str
    token_digest: str
    expires_at: datetime
    consumed_at: datetime | None = None


@dataclass(frozen=True)
class ConsentGrant:
    subject_id: str
    actor_id: str
    categories: frozenset[str]
    purposes: frozenset[str]
    revision: int
    revoked_at: datetime | None = None


def digest_token(token: str) -> str:
    return sha256(token.encode("utf-8")).hexdigest()


def consume_recovery(
    grant: RecoveryGrant, presented_token: str, now: datetime
) -> tuple[RecoveryGrant, dict[str, str]]:
    valid = (
        grant.consumed_at is None
        and now < grant.expires_at
        and compare_digest(grant.token_digest, digest_token(presented_token))
    )
    if not valid:
        return grant, {
            "public_code": "RESET_NOT_ACCEPTED",
            "audit_reason": "expired_consumed_or_mismatched",
        }
    return replace(grant, consumed_at=now), {
        "public_code": "RESET_ACCEPTED",
        "audit_reason": "single_use_grant_consumed",
    }


def check_consent(
    grant: ConsentGrant, subject_id: str, actor_id: str,
    category: str, purpose: str
) -> dict[str, object]:
    allowed = (
        grant.revoked_at is None
        and grant.subject_id == subject_id
        and grant.actor_id == actor_id
        and category in grant.categories
        and purpose in grant.purposes
    )
    return {
        "allowed": allowed,
        "reason": "active_category_grant" if allowed else "no_active_grant",
        "consent_revision": grant.revision,
    }


now = datetime.now(timezone.utc)
raw_token = "example-token-with-enough-test-entropy-not-for-production"
recovery = RecoveryGrant(
    account_id="provider-28",
    token_digest=digest_token(raw_token),
    expires_at=now + timedelta(minutes=15),
)
recovery, reset_result = consume_recovery(recovery, raw_token, now)

consent = ConsentGrant(
    subject_id="patient-104",
    actor_id="provider-28",
    categories=frozenset({"medications"}),
    purposes=frozenset({"care_delivery"}),
    revision=7,
)
decision = check_consent(
    consent, "patient-104", "provider-28", "medications", "care_delivery"
)

assert reset_result["public_code"] == "RESET_ACCEPTED"
assert decision == {
    "allowed": True,
    "reason": "active_category_grant",
    "consent_revision": 7,
}
assert consume_recovery(recovery, raw_token, now)[1]["public_code"] == "RESET_NOT_ACCEPTED"
Enter fullscreen mode Exit fullscreen mode

The 15-minute value is intentionally configuration, not a universal recommendation. The more important details are the comparison against server time and the transition to consumed_at. In production, token consumption and authenticator replacement belong in one transaction so a retry cannot use a token whose password change already committed. The raw token should arrive through a side channel and should not be stored as plaintext.

Revocation takes a different path. Mark the consent grant revoked, advance its revision, and make subsequent category checks deny it. Do not delete the old row that explains an earlier allow decision. Consumers with cached decisions need a bounded freshness rule and revision comparison; a queue message can speed invalidation, but it cannot be the only control because delivery and authorization are separate concerns.

One sharp edge deserves a test of its own: after a provider resets a password, revision 7 remains revision 7. If revision 7 was revoked before recovery, it stays revoked. A fresh credential doesn't resurrect an old grant.

Govern the audit story after deployment

Start with table-driven tests for unknown and known email addresses, repeated requests, expired tokens, a second use of the same token, and concurrent consumption. Verify the public status and body are consistent where account enumeration is possible. Then inspect the private event: it should include a request ID, pseudonymous account identifier when one exists, decision code, server timestamp, and throttling outcome, without copying the reset token or health values into logs.

Use 429 for throttling behavior only if that is the API contract, and keep the externally visible forgot-password message generic. Test pacing at the account and infrastructure levels without turning repeated requests into an account lockout, since locking a known account gives an attacker a denial-of-service tool. Delivery should happen asynchronously so the HTTP response isn't coupled to email latency, but enqueueing must still obey the same abuse controls.

The audit exercise is simple to describe and hard to fake later. Pick one protected read and ask the system to show which authentication event established the actor, which consent revision authorized the category and purpose, and whether either authority had been revoked at decision time. Also ask it to show a denied attempt without exposing the patient's health payload. If the evidence requires joining free-form logs by email address, the design isn't ready for an audit.

The rejected design is a single account_verified or has_consent boolean checked after reset. It remains valid for a low-risk preference that is truly binary, has no delegated actor, and needs no historical explanation. It is not suitable for health data categories in a marketplace: it cannot express purpose, recipient, revision, or selective revocation, and recovery code will eventually acquire authority it should never have.

Keep the two ledgers boring. Recovery grants establish a new authenticator; consent grants authorize a specific use of protected data. Bot resistance protects the first boundary, category checks protect the second, and the audit trail proves neither one silently crossed into the other.

References

Top comments (0)