Skip to content

Reference source · 0.1.0-dev.11

credential-lifecycle.test.ts

tests/credential-lifecycle.test.ts · Download original file

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sign } from 'node:crypto';
import { fixture, PATIENT } from '../src/reference/fixtures.ts';
import { TrustStore, makeIdentity, signCredential, signStatus } from '../src/identity.ts';
import { Client } from '../src/sdk.ts';
import { ReferenceHost } from '../src/host.ts';
import { SCOPES } from '../src/authorization.ts';
import { makeRotationProof } from '../src/rotation.ts';
import { b64, jsonBytes, parseObject, unb64 } from '../src/core.ts';

test('authority policies constrain identity kind, subject namespace, roles and credential duration', () => {
  const f = fixture();
  try {
    for (const credential of [signCredential(f.applicationAuthority, f.appIdentity.public, 'host', f.clock()),
      signCredential(f.applicationAuthority, makeIdentity('urn:another:app').public, 'application', f.clock()),
      signCredential(f.applicationAuthority, f.appIdentity.public, 'application', f.clock(), ['unverified-provider'])]) {
      assert.throws(() => f.trust.validate(credential, f.clock()), /authority_constraint_violation/);
    }
    const body = parseObject(unb64(f.appCredential.payload)); body.exp++;
    const payload = b64(jsonBytes(body));
    assert.throws(() => f.trust.validate({ payload, signature: b64(sign(null, Buffer.from(`OPRP-CREDENTIAL-0.1.${payload}`), f.applicationAuthority.privateKey)) }, f.clock()), /authority_constraint_violation/);
  } finally { f.host.close(); f.trust.close(); }
});

test('signed status rejects stale caches, rollback, equivocation and forgery', () => {
  const f = fixture(), trust = new TrustStore(f.trust.policies);
  try {
    assert.throws(() => trust.validate(f.appCredential, f.clock()), /missing_issuer_status/);
    const status = signStatus(f.applicationAuthority, 4, f.clock()); trust.ingestStatus(status, f.clock());
    assert.equal(trust.validate(f.appCredential, f.clock()).subject, f.app.subject);
    trust.ingestStatus(status, f.clock());
    assert.throws(() => trust.ingestStatus(signStatus(f.applicationAuthority, 3, f.clock()), f.clock()), /status_rollback_or_equivocation/);
    assert.throws(() => trust.ingestStatus(signStatus(f.applicationAuthority, 4, f.clock(), { revoked_serials: [f.app.serial] }), f.clock()), /status_rollback_or_equivocation/);
    assert.throws(() => trust.ingestStatus({ ...status, signature: b64(Buffer.alloc(3309)) }, f.clock()), /invalid_signature/);
    assert.throws(() => trust.validate(f.appCredential, f.clock() + 600), /stale_issuer_status/);
  } finally { trust.close(); f.host.close(); f.trust.close(); }
});

test('serial and key revocation and issuer disablement survive restart and cannot be undone by a later status', () => {
  const directory = mkdtempSync(join(tmpdir(), 'oprp-trust-')); const f = fixture(); let trust = new TrustStore(f.trust.policies, join(directory, 'trust.sqlite'));
  try {
    trust.ingestStatus(signStatus(f.applicationAuthority, 2, f.clock(), { revoked_signing_kids: [f.app.signingKid] }), f.clock());
    trust.ingestStatus(signStatus(f.hostingAuthority, 2, f.clock()), f.clock());
    trust.close(); trust = new TrustStore(f.trust.policies, join(directory, 'trust.sqlite'));
    trust.ingestStatus(signStatus(f.applicationAuthority, 3, f.clock()), f.clock());
    assert.throws(() => trust.validate(signCredential(f.applicationAuthority, f.appIdentity.public, 'application', f.clock()), f.clock()), /revoked_credential/);
    trust.disableIssuer(f.hostingAuthority.issuer); trust.close(); trust = new TrustStore(f.trust.policies, join(directory, 'trust.sqlite'));
    assert.throws(() => trust.validate(f.hostCredential, f.clock()), /disabled_issuer/);
  } finally { trust.close(); f.host.close(); f.trust.close(); rmSync(directory, { recursive: true, force: true }); }
});

test('same-key credential renewal preserves a grant without repeating patient approval', async () => {
  const f = fixture();
  try {
    const grant = await f.client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 1000, refresh_requested: true });
    assert.equal(grant.status, 'granted'); if (grant.status !== 'granted') return;
    const renewed = signCredential(f.applicationAuthority, f.appIdentity.public, 'application', f.clock());
    f.trust.revoke(f.app.issuer, 'serial', f.app.serial); f.client.credential = renewed;
    assert.equal((await f.client.readDemographics(PATIENT)).resource.id, PATIENT);
    f.advance(301); await f.client.readDemographics(PATIENT);
    assert.equal(f.client.authorization!.grant_id, grant.grant_id); assert.equal(f.client.authorization!.authorized_until, grant.authorized_until);
    assert.equal(f.host.authorization.approvalTasks(f.clock()).length, 0);
  } finally { f.host.close(); f.trust.close(); }
});

test('planned dual-key rotation retires old keys and refresh credentials without expanding authority', async () => {
  const f = fixture();
  try {
    const grant = await f.client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 1000, refresh_requested: true });
    assert.equal(grant.status, 'granted'); if (grant.status !== 'granted') return;
    const identity = makeIdentity(f.app.subject), credential = signCredential(f.applicationAuthority, identity.public, 'application', f.clock());
    const client = new Client({ identity, credential, hostCredential: f.hostCredential, hostId: f.hostIdentity.public.subject, trust: f.trust, transport: async message => f.host.handle(message, f.clock()), clock: f.clock });
    await assert.rejects(client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 1000, refresh_requested: true }), /requester_rotation_required/);
    const proof = makeRotationProof(f.appIdentity, f.appCredential, identity, credential, f.hostIdentity.public.subject, f.clock());
    await assert.rejects(client.rotateRequester({ ...proof, old_signature: b64(Buffer.alloc(3309)) }), /invalid_rotation/);
    assert.equal((await client.rotateRequester(proof)).status, 'ok');
    await client.replaceToken(grant.grant_id);
    assert.deepEqual(client.authorization!.scope, grant.scope); assert.equal(client.authorization!.authorized_until, grant.authorized_until);
    assert.equal((await client.readDemographics(PATIENT)).resource.id, PATIENT);
    await assert.rejects(f.client.readDemographics(PATIENT), /requester_rotation_required/);
    await assert.rejects(f.client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 1000, refresh_requested: false }), /requester_rotation_required/);
    await assert.rejects(client.exchange({ operation: 'refresh', refresh_token: grant.refresh_token, request_id: 'retired-refresh' }), /invalid_refresh/);
    assert(client.authorization!.refresh_token); f.advance(301); await client.readDemographics(PATIENT);
    await assert.rejects(client.listLabs(PATIENT), /insufficient_scope/);
    f.host.authorization.revoke(grant.grant_id); await assert.rejects(client.replaceToken(grant.grant_id), /authorization_expired_or_revoked/);
  } finally { f.host.close(); f.trust.close(); }
});

test('rotation retry survives Host restart and old-proof expiry; altered retry and old keys stay rejected', async () => {
  const directory = mkdtempSync(join(tmpdir(), 'oprp-rotation-')); const f = fixture(join(directory, 'host.sqlite')); let host = f.host;
  try {
    await f.client.requestAccess({ patient: PATIENT, scopes: [SCOPES.demographicsRead], requested_duration_seconds: 1000, refresh_requested: true });
    const identity = makeIdentity(f.app.subject), credential = signCredential(f.applicationAuthority, identity.public, 'application', f.clock());
    const proof = makeRotationProof(f.appIdentity, f.appCredential, identity, credential, f.hostIdentity.public.subject, f.clock(), 'stable-rotation');
    const client = new Client({ identity, credential, hostCredential: f.hostCredential, hostId: f.hostIdentity.public.subject, trust: f.trust, transport: async message => host.handle(message, f.clock()), clock: f.clock });
    const result = await client.rotateRequester(proof);
    host.close(); host = new ReferenceHost(join(directory, 'host.sqlite'), f.hostIdentity, f.hostCredential, f.trust, f.policy, f.representatives);
    f.advance(121); f.trust.revoke(f.app.issuer, 'serial', f.app.serial);
    assert.deepEqual(await client.rotateRequester(proof), result);
    await assert.rejects(client.rotateRequester({ ...proof, old_signature: b64(Buffer.alloc(3309)) }), /rotation_id_conflict/);
    await client.replaceToken(f.client.authorization!.grant_id); assert.equal((await client.readDemographics(PATIENT)).resource.id, PATIENT);
  } finally { host.close(); f.trust.close(); rmSync(directory, { recursive: true, force: true }); }
});