Reference source · 0.1.0-dev.11
authorization.ts
src/authorization.ts · Download original file
import { sign, verify, randomBytes, hkdfSync, createHash } from 'node:crypto';
import type { HostDatabase } from './host-storage.ts';
import type { Identity, Credential, CredentialBody } from './identity.ts';
import { publicKey, TrustStore } from './identity.ts';
import { b64, unb64, jsonBytes, parseObject, exact, id, integer, fingerprint, newId, seconds, requireThat, ProtocolError } from './core.ts';
import { protect, reveal } from './opaque.ts';
import { RESOURCE_SCOPES } from './resource-profile.ts';
import { rotationBody, validateRotation } from './rotation.ts';
import type { RotationProof } from './rotation.ts';
export const SCOPES = {
demographicsRead: 'patient/Patient.r', demographicsUpdate: 'patient/Patient.u',
labsRead: 'patient/Observation.rs?category=laboratory', labsCreate: 'patient/Observation.c?category=laboratory',
conditionRead: RESOURCE_SCOPES.Condition.read,
conditionCreate: RESOURCE_SCOPES.Condition.create,
conditionUpdate: RESOURCE_SCOPES.Condition.update,
conditionDelete: RESOURCE_SCOPES.Condition.delete,
allergyIntoleranceRead: RESOURCE_SCOPES.AllergyIntolerance.read,
allergyIntoleranceCreate: RESOURCE_SCOPES.AllergyIntolerance.create,
allergyIntoleranceUpdate: RESOURCE_SCOPES.AllergyIntolerance.update,
allergyIntoleranceDelete: RESOURCE_SCOPES.AllergyIntolerance.delete,
medicationStatementRead: RESOURCE_SCOPES.MedicationStatement.read,
medicationStatementCreate: RESOURCE_SCOPES.MedicationStatement.create,
medicationStatementUpdate: RESOURCE_SCOPES.MedicationStatement.update,
medicationStatementDelete: RESOURCE_SCOPES.MedicationStatement.delete,
encounterRead: RESOURCE_SCOPES.Encounter.read,
encounterCreate: RESOURCE_SCOPES.Encounter.create,
encounterUpdate: RESOURCE_SCOPES.Encounter.update,
encounterDelete: RESOURCE_SCOPES.Encounter.delete,
procedureRead: RESOURCE_SCOPES.Procedure.read,
procedureCreate: RESOURCE_SCOPES.Procedure.create,
procedureUpdate: RESOURCE_SCOPES.Procedure.update,
procedureDelete: RESOURCE_SCOPES.Procedure.delete,
immunizationRead: RESOURCE_SCOPES.Immunization.read,
immunizationCreate: RESOURCE_SCOPES.Immunization.create,
immunizationUpdate: RESOURCE_SCOPES.Immunization.update,
immunizationDelete: RESOURCE_SCOPES.Immunization.delete,
recordExport: 'oprp/record.export',
recordTransfer: 'oprp/record.transfer',
} as const;
export type Scope = typeof SCOPES[keyof typeof SCOPES];
export const JWT_ALG = 'OPRP-MLDSA65-v0.1'; // Private experimental identifier, not a registered JOSE algorithm.
export type AccessRequest = { request_id: string; patient: string; scopes: Scope[]; requested_duration_seconds: number; refresh_requested: boolean; actor_proof?: ActorProof };
export type ActorProof = { credential: Credential; payload: string; signature: string };
export type Representative = { actor: string; patient: string; scopes: Scope[]; manageScopes: Scope[]; active: boolean; expiresAt: number };
export type Decision = { status: 'granted'; grant_id: string; access_token: string; token_expires_at: number; authorized_until: number; scope: Scope[]; refresh_token?: string; refresh_expires_at?: number }
| { status: 'pending'; request_id: string; approval_required: Scope[]; expires_at: number }
| { status: 'denied'; reason: string };
export type RequestContext = { requester: CredentialBody; actor?: string };
export type DecisionProvider = (request: AccessRequest, context: RequestContext, now: number) => { scopes: Record<string, 'allow' | 'approval_required' | 'deny'>; maximumDuration: number };
export type AuthorizationOptions = { pendingLifetime?: number; requestWindow?: number; maxRequestsPerWindow?: number; maxPendingPerPatient?: number };
type Grant = { grant_id: string; app: string; patient: string; scopes: Scope[]; until: number; kid: string; active: boolean; actor?: string; refresh: boolean };
type Claims = { iss: string; aud: string; sub: string; iat: number; nbf: number; exp: number; jti: string; patient: string; scope: Scope[]; grant_id: string; cnf: { oprp_signing_kid: string }; actor: string | null };
export function makeActorProof(person: Identity, credential: Credential, app: string, patient: string, requestId: string, now: number): ActorProof {
const payload = b64(jsonBytes({ actor: person.public.subject, app, patient, request_id: requestId, expires_at: now + 120 }));
return { credential, payload, signature: b64(sign(null, Buffer.from(`OPRP-ACTOR-0.1.${payload}`), person.signingPrivate)) };
}
export class AuthorizationService {
db: HostDatabase; host: Identity; trust: TrustStore; policy: DecisionProvider;
representatives: Representative[]; tokenLifetime: number;
options: Required<AuthorizationOptions>;
constructor(db: HostDatabase, host: Identity, trust: TrustStore, policy: DecisionProvider, representatives: Representative[] = [], tokenLifetime = 300, options: AuthorizationOptions = {}) {
this.db = db; this.host = host; this.trust = trust; this.policy = policy;
this.representatives = representatives; this.tokenLifetime = tokenLifetime;
this.options = { pendingLifetime: 3600, requestWindow: 60, maxRequestsPerWindow: 10, maxPendingPerPatient: 5, ...options };
Object.values(this.options).forEach(value => { integer(value); requireThat(value > 0); });
db.exec(`CREATE TABLE IF NOT EXISTS grants (id TEXT PRIMARY KEY, body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS auth_requests (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, body TEXT NOT NULL, app TEXT NOT NULL, actor TEXT, status TEXT NOT NULL, grant_id TEXT);
CREATE TABLE IF NOT EXISTS approval_tasks (id TEXT PRIMARY KEY, body TEXT NOT NULL, state TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS auth_lifecycle (id TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, app_kid TEXT NOT NULL, reason TEXT);
CREATE TABLE IF NOT EXISTS request_attempts (app TEXT NOT NULL, patient TEXT NOT NULL, at INTEGER NOT NULL);
CREATE INDEX IF NOT EXISTS request_attempt_window ON request_attempts(app,patient,at);
CREATE TABLE IF NOT EXISTS refreshes (hash TEXT PRIMARY KEY, grant_id TEXT NOT NULL, used INTEGER NOT NULL DEFAULT 0);
CREATE TABLE IF NOT EXISTS refresh_rotations (hash TEXT PRIMARY KEY, request_id TEXT NOT NULL, at INTEGER NOT NULL, successor_hash TEXT NOT NULL, receipt TEXT);`);
db.exec(`CREATE TABLE IF NOT EXISTS requester_keys (app TEXT PRIMARY KEY, signing_kid TEXT NOT NULL, encryption_kid TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS requester_rotations (id TEXT PRIMARY KEY, digest TEXT NOT NULL, app TEXT NOT NULL, signing_kid TEXT NOT NULL, encryption_kid TEXT NOT NULL, body TEXT NOT NULL);`);
}
transaction<T>(fn: () => T): T {
this.db.exec('BEGIN IMMEDIATE');
try { const result = fn(); this.db.exec('COMMIT'); return result; }
catch (e) { this.db.exec('ROLLBACK'); throw e; }
}
validateRequest(request: AccessRequest): void {
const keys = ['request_id', 'patient', 'scopes', 'requested_duration_seconds', 'refresh_requested'];
if (request.actor_proof !== undefined) keys.push('actor_proof');
exact(request, keys); id(request.request_id); id(request.patient);
requireThat(Array.isArray(request.scopes) && request.scopes.length > 0 && request.scopes.length <= Object.values(SCOPES).length && new Set(request.scopes).size === request.scopes.length && request.scopes.every(x => Object.values(SCOPES).includes(x)), 'unsupported_scope');
integer(request.requested_duration_seconds);
requireThat(request.requested_duration_seconds >= 1 && request.requested_duration_seconds <= 31536000, 'invalid_duration');
requireThat(typeof request.refresh_requested === 'boolean');
}
actor(request: AccessRequest, app: CredentialBody, now: number): string | undefined {
if (!request.actor_proof) return undefined;
const proof = request.actor_proof; exact(proof, ['credential', 'payload', 'signature']);
requireThat(typeof proof.payload === 'string' && proof.payload.length <= 3000);
const person = this.trust.validate(proof.credential, now, undefined, 'person');
const body = parseObject(unb64(proof.payload));
exact(body, ['actor', 'app', 'patient', 'request_id', 'expires_at']); integer(body.expires_at);
requireThat(body.actor === person.subject && body.app === app.subject && body.patient === request.patient && body.request_id === request.request_id && now < body.expires_at && body.expires_at <= now + 120, 'invalid_actor');
requireThat(verify(null, Buffer.from(`OPRP-ACTOR-0.1.${proof.payload}`), publicKey(person.signingKey, 'signing'), unb64(proof.signature, 3309)), 'invalid_actor');
const authority = this.representatives.find(x => x.actor === person.subject && x.patient === request.patient && x.active && x.expiresAt > now);
requireThat(authority && request.scopes.every(s => authority.scopes.includes(s)), 'representative_not_authorized');
return person.subject;
}
request(request: AccessRequest, app: CredentialBody, now: number): Decision {
this.validateRequest(request); requireThat(app.kind === 'application', 'wrong_identity_kind');
const actor = this.actor(request, app, now);
const requestKey = fingerprint(jsonBytes([app.subject, request.request_id]));
const digest = fingerprint(jsonBytes(request));
return this.transaction(() => {
this.expirePending(now);
const prior = this.db.prepare('SELECT * FROM auth_requests WHERE id=?').get(requestKey) as any;
if (prior) {
requireThat(prior.fingerprint === digest, 'request_id_conflict');
this.requestHolder(requestKey, app);
if (prior.status === 'granted') return this.tokens(this.grant(prior.grant_id), app, now, true);
if (prior.status !== 'pending') return this.terminal(prior.id);
return this.pending(request, app, actor, now);
}
this.db.prepare('DELETE FROM request_attempts WHERE at<=?').run(now - this.options.requestWindow);
const attempts = this.db.prepare('SELECT count(*) AS count FROM request_attempts WHERE app=? AND patient=?').get(app.subject, request.patient) as any;
requireThat(attempts.count < this.options.maxRequestsPerWindow, 'rate_limited');
const evaluated = this.policy(request, { requester: app, actor }, now);
integer(evaluated.maximumDuration); requireThat(evaluated.maximumDuration > 0);
const status = request.scopes.some(s => evaluated.scopes[s] === 'deny' || !evaluated.scopes[s]) ? 'denied'
: request.scopes.some(s => evaluated.scopes[s] === 'approval_required') ? 'pending' : 'granted';
if (status === 'pending') {
const pending = (this.db.prepare('SELECT body FROM auth_requests WHERE status=?').all('pending') as any[]).filter(row => JSON.parse(row.body).patient === request.patient).length;
requireThat(pending < this.options.maxPendingPerPatient, 'approval_queue_full');
}
this.db.prepare('INSERT INTO request_attempts VALUES (?, ?, ?)').run(app.subject, request.patient, now);
const grant = status === 'granted' ? this.createGrant(request, app, actor, now, evaluated.maximumDuration) : undefined;
this.db.prepare('INSERT INTO auth_requests VALUES (?, ?, ?, ?, ?, ?, ?)').run(requestKey, digest, JSON.stringify(request), app.subject, actor ?? null, status, grant?.grant_id ?? null);
const expiresAt = now + this.options.pendingLifetime;
this.db.prepare('INSERT INTO auth_lifecycle VALUES (?, ?, ?, ?, ?)').run(requestKey, now, expiresAt, app.signingKid, status === 'denied' ? 'permission_denied' : null);
if (status === 'pending') this.db.prepare('INSERT INTO approval_tasks VALUES (?, ?, ?)').run(requestKey, JSON.stringify({ app: app.subject, actor: actor ?? null, patient: request.patient, request_id: request.request_id, requested_duration_seconds: request.requested_duration_seconds, scopes: request.scopes, approval_required: request.scopes.filter(s => evaluated.scopes[s] === 'approval_required'), created_at: now, expires_at: expiresAt }), 'queued');
if (grant) return this.tokens(grant, app, now, true);
if (status === 'denied') return { status: 'denied', reason: 'permission_denied' };
return { status: 'pending', request_id: request.request_id, approval_required: request.scopes.filter(s => evaluated.scopes[s] === 'approval_required'), expires_at: expiresAt };
});
}
pending(request: AccessRequest, app: CredentialBody, actor: string | undefined, now: number): Decision {
const requestKey = fingerprint(jsonBytes([app.subject, request.request_id]));
const policy = this.policy(request, { requester: app, actor }, now);
const rep = actor ? this.representatives.find(x => x.actor === actor && x.patient === request.patient && x.active && x.expiresAt > now) : undefined;
if ((actor && (!rep || !request.scopes.every(s => rep.scopes.includes(s)))) || request.scopes.some(s => !['allow', 'approval_required'].includes(policy.scopes[s]))) {
this.finishPending(requestKey, 'denied', actor && !rep ? 'representative_not_authorized' : 'permission_denied');
return this.terminal(requestKey);
}
if (request.scopes.every(s => policy.scopes[s] === 'allow')) {
const grant = this.createGrant(request, app, actor, now, policy.maximumDuration);
this.db.prepare('UPDATE auth_requests SET status=?, grant_id=? WHERE id=?').run('granted', grant.grant_id, requestKey);
this.db.prepare('UPDATE approval_tasks SET state=? WHERE id=?').run('completed', requestKey);
return this.tokens(grant, app, now, true);
}
const lifecycle = this.db.prepare('SELECT expires_at FROM auth_lifecycle WHERE id=?').get(requestKey) as any;
return { status: 'pending', request_id: request.request_id, approval_required: request.scopes.filter(s => policy.scopes[s] === 'approval_required'), expires_at: lifecycle.expires_at };
}
status(app: CredentialBody, requestId: string, now: number): Decision {
id(requestId);
return this.transaction(() => {
this.expirePending(now);
const row = this.db.prepare('SELECT * FROM auth_requests WHERE id=?').get(fingerprint(jsonBytes([app.subject, requestId]))) as any;
requireThat(row, 'unknown_pending_request'); this.requestHolder(row.id, app);
if (row.status === 'granted') return this.tokens(this.grant(row.grant_id), app, now, true);
if (row.status !== 'pending') return this.terminal(row.id);
return this.pending(JSON.parse(row.body), app, row.actor ?? undefined, now);
});
}
requestHolder(key: string, app: CredentialBody): void {
const row = this.db.prepare('SELECT app_kid FROM auth_lifecycle WHERE id=?').get(key) as any;
requireThat(!row || row.app_kid === app.signingKid, 'wrong_token_holder');
}
terminal(key: string): Decision {
const row = this.db.prepare('SELECT reason FROM auth_lifecycle WHERE id=?').get(key) as any;
return { status: 'denied', reason: row?.reason ?? 'request_expired' };
}
finishPending(key: string, status: string, reason: string): void {
this.db.prepare('UPDATE auth_requests SET status=? WHERE id=? AND status=?').run(status, key, 'pending');
this.db.prepare('UPDATE auth_lifecycle SET reason=? WHERE id=?').run(reason, key);
this.db.prepare('UPDATE approval_tasks SET state=? WHERE id=?').run(status, key);
}
expirePending(now: number): void {
const rows = this.db.prepare('SELECT r.id FROM auth_requests r LEFT JOIN auth_lifecycle l ON r.id=l.id WHERE r.status=? AND (l.expires_at IS NULL OR l.expires_at<=?)').all('pending', now) as any[];
rows.forEach(row => this.finishPending(row.id, 'expired', 'request_expired'));
}
approver(request: Pick<AccessRequest, 'patient' | 'scopes'>, authenticatedApprover: string, now: number): void {
const authority = this.representatives.find(x => x.actor === authenticatedApprover && x.patient === request.patient && x.active && x.expiresAt > now);
requireThat(authenticatedApprover === request.patient || (authority && request.scopes.every(s => authority.manageScopes.includes(s))), 'approver_not_authorized');
}
// Trusted Host integration point. Not exposed as an unauthenticated protocol endpoint.
approve(app: CredentialBody, requestId: string, authenticatedApprover: string, now: number): void {
this.transaction(() => this.expirePending(now));
this.transaction(() => {
const row = this.db.prepare('SELECT * FROM auth_requests WHERE id=?').get(fingerprint(jsonBytes([app.subject, requestId]))) as any;
requireThat(row && row.status === 'pending', 'unknown_pending_request');
const request = JSON.parse(row.body) as AccessRequest;
this.requestHolder(row.id, app); this.approver(request, authenticatedApprover, now);
const actor: string | undefined = row.actor ?? undefined;
if (actor) {
const rep = this.representatives.find(x => x.actor === actor && x.patient === request.patient && x.active && x.expiresAt > now);
requireThat(rep && request.scopes.every(s => rep.scopes.includes(s)), 'representative_not_authorized');
}
const current = this.policy(request, { requester: app, actor }, now);
requireThat(request.scopes.every(s => ['allow', 'approval_required'].includes(current.scopes[s])), 'permission_denied');
const grant = this.createGrant(request, app, actor, now, current.maximumDuration);
this.db.prepare('UPDATE auth_requests SET status=?, grant_id=? WHERE id=?').run('granted', grant.grant_id, row.id);
this.db.prepare('UPDATE approval_tasks SET state=? WHERE id=?').run('completed', row.id);
});
}
deny(app: CredentialBody, requestId: string, authenticatedApprover: string, now: number): void {
this.transaction(() => this.expirePending(now));
this.transaction(() => {
const key = fingerprint(jsonBytes([app.subject, requestId])); const row = this.db.prepare('SELECT * FROM auth_requests WHERE id=?').get(key) as any;
requireThat(row && row.status === 'pending', 'unknown_pending_request'); this.requestHolder(key, app);
this.approver(JSON.parse(row.body), authenticatedApprover, now); this.finishPending(key, 'denied', 'patient_denied');
});
}
cancel(app: CredentialBody, requestId: string, now: number): Decision {
id(requestId);
return this.transaction(() => {
this.expirePending(now);
const key = fingerprint(jsonBytes([app.subject, requestId])); const row = this.db.prepare('SELECT * FROM auth_requests WHERE id=?').get(key) as any;
requireThat(row, 'unknown_pending_request'); this.requestHolder(key, app);
requireThat(row.status !== 'granted', 'request_not_pending');
if (row.status === 'pending') this.finishPending(key, 'cancelled', 'request_cancelled');
return this.terminal(key);
});
}
createGrant(request: AccessRequest, app: CredentialBody, actor: string | undefined, now: number, cap: number): Grant {
integer(cap); requireThat(cap > 0);
const authority = actor ? this.representatives.find(x => x.actor === actor && x.patient === request.patient && x.active && x.expiresAt > now) : undefined;
const grant: Grant = { grant_id: newId(), app: app.subject, patient: request.patient, scopes: request.scopes,
until: Math.min(now + request.requested_duration_seconds, now + cap, authority?.expiresAt ?? Infinity),
kid: app.signingKid, active: true, actor, refresh: request.refresh_requested };
this.saveGrant(grant); return grant;
}
grant(grantId: string): Grant {
const row = this.db.prepare('SELECT body FROM grants WHERE id=?').get(grantId) as any;
requireThat(row, 'unknown_grant'); return JSON.parse(row.body);
}
saveGrant(grant: Grant): void {
this.db.prepare('INSERT INTO grants VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET body=excluded.body').run(grant.grant_id, JSON.stringify(grant));
}
current(grant: Grant, app: CredentialBody, now: number): void {
requireThat(grant.active && now < grant.until, 'authorization_expired_or_revoked');
requireThat(grant.app === app.subject && grant.kid === app.signingKid, 'wrong_token_holder');
if (grant.actor) {
const rep = this.representatives.find(x => x.actor === grant.actor && x.patient === grant.patient && x.active && x.expiresAt > now);
requireThat(rep && grant.scopes.every(s => rep.scopes.includes(s)), 'representative_not_authorized');
}
}
tokens(grant: Grant, app: CredentialBody, now: number, makeRefresh: boolean): Decision {
this.current(grant, app, now);
const exp = Math.min(now + this.tokenLifetime, grant.until);
const header = b64(jsonBytes({ alg: JWT_ALG, typ: 'JWT', kid: this.host.public.signingKid }));
const claims: Claims = { iss: this.host.public.subject, aud: this.host.public.subject, sub: app.subject, iat: now, nbf: now, exp, jti: newId(), patient: grant.patient,
scope: grant.scopes, grant_id: grant.grant_id, cnf: { oprp_signing_kid: app.signingKid }, actor: grant.actor ?? null };
const payload = b64(jsonBytes(claims));
const input = `${header}.${payload}`;
const result: Extract<Decision, {status: 'granted'}> = { status: 'granted', grant_id: grant.grant_id, access_token: `${input}.${b64(sign(null, Buffer.from(input), this.host.signingPrivate))}`, token_expires_at: exp, authorized_until: grant.until, scope: grant.scopes };
if (makeRefresh && grant.refresh) {
const refresh = b64(randomBytes(32));
this.db.prepare('INSERT INTO refreshes(hash, grant_id) VALUES (?, ?)').run(fingerprint(Buffer.from(refresh)), grant.grant_id);
result.refresh_token = refresh; result.refresh_expires_at = grant.until;
}
return result;
}
refresh(token: string, app: CredentialBody, now: number, requestId = newId()): Decision {
const secret = unb64(token, 32); id(requestId);
try {
// Expiry cleanup is independent of whether this refresh is later accepted or rejected.
this.db.prepare('UPDATE refresh_rotations SET receipt=NULL WHERE at<=? AND receipt IS NOT NULL').run(now - 120);
const result = this.transaction(() => {
const row = this.db.prepare('SELECT * FROM refreshes WHERE hash=?').get(fingerprint(Buffer.from(token))) as any;
requireThat(row, 'invalid_refresh');
const grant = this.grant(row.grant_id); this.current(grant, app, now);
const context = jsonBytes(['OPRP-REFRESH-RECOVERY-0.1', grant.grant_id, app.subject, app.signingKid, requestId]);
if (row.used) {
const rotation = this.db.prepare('SELECT * FROM refresh_rotations WHERE hash=?').get(row.hash) as any;
if (rotation?.request_id === requestId) {
requireThat(rotation.receipt && now < rotation.at + 120, 'refresh_recovery_expired');
const successor = this.db.prepare('SELECT used FROM refreshes WHERE hash=?').get(rotation.successor_hash) as any;
requireThat(successor && !successor.used, 'refresh_superseded');
const key = Buffer.from(hkdfSync('sha512', secret, createHash('sha512').update(context).digest(), Buffer.from('OPRP-0.1/refresh-receipt'), 32));
try {
const cached = reveal(rotation.receipt, key, context);
requireThat(typeof cached.refresh_token === 'string' && fingerprint(Buffer.from(cached.refresh_token)) === rotation.successor_hash, 'invalid_refresh_receipt');
return { ...this.tokens(grant, app, now, false), refresh_token: cached.refresh_token, refresh_expires_at: grant.until } as Decision;
} finally { key.fill(0); }
}
grant.active = false; this.saveGrant(grant); return undefined;
}
this.db.prepare('UPDATE refreshes SET used=1 WHERE hash=?').run(row.hash);
const issued = this.tokens(grant, app, now, true);
requireThat(issued.status === 'granted' && issued.refresh_token, 'refresh_denied');
const key = Buffer.from(hkdfSync('sha512', secret, createHash('sha512').update(context).digest(), Buffer.from('OPRP-0.1/refresh-receipt'), 32));
try {
this.db.prepare('INSERT INTO refresh_rotations VALUES (?, ?, ?, ?, ?)').run(row.hash, requestId, now, fingerprint(Buffer.from(issued.refresh_token)), protect({ refresh_token: issued.refresh_token }, key, context));
} finally { key.fill(0); }
return issued;
});
if (!result) throw new ProtocolError('refresh_reuse_detected');
return result;
} finally { secret.fill(0); }
}
authorize(token: string, app: CredentialBody, patient: string, scope: Scope | undefined, now: number): Claims {
requireThat(typeof token === 'string' && token.length <= 14000);
const parts = token.split('.'); requireThat(parts.length === 3, 'invalid_token');
const header = parseObject(unb64(parts[0])); exact(header, ['alg', 'typ', 'kid']);
requireThat(header.alg === JWT_ALG && header.typ === 'JWT' && header.kid === this.host.public.signingKid, 'invalid_token');
requireThat(verify(null, Buffer.from(`${parts[0]}.${parts[1]}`), publicKey(this.host.public.signingKey, 'signing'), unb64(parts[2], 3309)), 'invalid_token');
const c = parseObject(unb64(parts[1])) as Claims;
exact(c, ['iss','aud','sub','iat','nbf','exp','jti','patient','scope','grant_id','cnf','actor']);
[c.iat,c.nbf,c.exp].forEach(integer);
requireThat(c.iss === this.host.public.subject && c.aud === this.host.public.subject && c.sub === app.subject && c.patient === patient && c.nbf <= now && c.iat <= now && now < c.exp, 'invalid_token');
requireThat(c.cnf?.oprp_signing_kid === app.signingKid && Array.isArray(c.scope) && (scope === undefined || c.scope.includes(scope)), 'insufficient_scope');
const grant = this.grant(c.grant_id); this.current(grant, app, now);
requireThat(grant.patient === patient && (scope === undefined || grant.scopes.includes(scope)) && c.scope.every(s => grant.scopes.includes(s)) && c.exp <= grant.until && c.actor === (grant.actor ?? null), 'invalid_token');
return c;
}
revoke(grantId: string): void { const grant = this.grant(grantId); grant.active = false; this.saveGrant(grant); }
requester(app: CredentialBody): void {
const prior = this.db.prepare('SELECT * FROM requester_keys WHERE app=?').get(app.subject) as any;
requireThat(!prior || (prior.signing_kid === app.signingKid && prior.encryption_kid === app.encryptionKid), 'requester_rotation_required');
// Existing grants pin the signing key when upgrading a pre-rotation database.
if (!prior) {
const grants = (this.db.prepare('SELECT body FROM grants').all() as any[]).map(row => JSON.parse(row.body) as Grant).filter(grant => grant.app === app.subject && grant.active);
requireThat(grants.every(grant => grant.kid === app.signingKid), 'requester_rotation_required');
this.db.prepare('INSERT INTO requester_keys VALUES (?, ?, ?)').run(app.subject, app.signingKid, app.encryptionKid);
}
}
rotate(proof: RotationProof, app: CredentialBody, now: number): Record<string, any> {
const body = rotationBody(proof), digest = fingerprint(jsonBytes(proof));
const key = fingerprint(jsonBytes([app.subject, body.rotation_id]));
return this.transaction(() => {
const prior = this.db.prepare('SELECT * FROM requester_rotations WHERE id=?').get(key) as any;
if (prior) {
requireThat(prior.digest === digest && prior.app === app.subject && prior.signing_kid === app.signingKid && prior.encryption_kid === app.encryptionKid, 'rotation_id_conflict');
this.requester(app); return JSON.parse(prior.body);
}
const validated = validateRotation(proof, this.trust, this.host.public.subject, app, now);
this.requester(validated.oldIdentity);
const grants = (this.db.prepare('SELECT body FROM grants').all() as any[]).map(row => JSON.parse(row.body) as Grant).filter(grant => grant.app === app.subject);
for (const grant of grants) {
grant.kid = app.signingKid; this.saveGrant(grant);
this.db.prepare('DELETE FROM refresh_rotations WHERE hash IN (SELECT hash FROM refreshes WHERE grant_id=?)').run(grant.grant_id);
this.db.prepare('DELETE FROM refreshes WHERE grant_id=?').run(grant.grant_id);
}
this.db.prepare('UPDATE auth_lifecycle SET app_kid=? WHERE id IN (SELECT id FROM auth_requests WHERE app=?)').run(app.signingKid, app.subject);
this.db.prepare('UPDATE requester_keys SET signing_kid=?, encryption_kid=? WHERE app=?').run(app.signingKid, app.encryptionKid, app.subject);
const result = { status: 'ok', rotation_id: body.rotation_id, grants_rebound: grants.length };
this.db.prepare('INSERT INTO requester_rotations VALUES (?, ?, ?, ?, ?, ?)').run(key, digest, app.subject, app.signingKid, app.encryptionKid, JSON.stringify(result));
return result;
});
}
replaceToken(grantId: string, app: CredentialBody, now: number, refresh = true): Decision {
id(grantId); return this.transaction(() => this.tokens(this.grant(grantId), app, now, refresh));
}
// A Host-owned worker consumes these tasks using its chosen app/email/approval interface.
// The reference demo simulates this integration; it never sends patient notifications.
approvalTasks(now = seconds()): Record<string, any>[] {
return this.transaction(() => { this.expirePending(now); return (this.db.prepare('SELECT body FROM approval_tasks WHERE state=? ORDER BY id').all('queued') as any[]).map(row => JSON.parse(row.body)); });
}
}