Source: caep/subjects.js

/**
 * Helpers for RFC 9493 Subject Identifiers, as carried in the SET `sub_id` claim.
 */
export const Subject = Object.freeze({
  issSub: (iss, sub) => ({ format: 'iss_sub', iss, sub }),
  email: (email) => ({ format: 'email', email }),
  phone: (phone_number) => ({ format: 'phone_number', phone_number }),
  opaque: (id) => ({ format: 'opaque', id }),
  account: (uri) => ({ format: 'account', uri }),
  uri: (uri) => ({ format: 'uri', uri }),
  did: (url) => ({ format: 'did', url }),
  aliases: (...identifiers) => ({ format: 'aliases', identifiers }),
  /** SSF complex subject, e.g. { user: Subject.issSub(...), session: Subject.opaque(sid) } */
  complex: (members) => ({ format: 'complex', ...members }),
});

const COMPLEX_MEMBERS = ['user', 'session', 'device', 'application', 'tenant', 'org_unit', 'group'];

/**
 * Tests whether a subject identifier refers to a known identity.
 *
 * `identity` fields: iss, sub, sid (session id), email and email_verified, phone_number and
 * phone_number_verified, plus any of device/application/tenant/org_unit/group as opaque ids.
 *
 * - An email or phone number identifies somebody only when the OP said it was verified
 *   (`email_verified` / `phone_number_verified` true): OIDC Core ยง5.7 makes neither a stable or unique
 *   identifier, and an unverified one may be anybody's.
 * - A top-level opaque id is compared with `sub`. A session is named inside a complex subject.
 * - In a complex subject every member must match, and the members this identity can't be checked
 *   against count as a non-match: an event about one session matches only an identity holding that `sid`.
 *
 * @param {Record<string, any>} subId  An RFC 9493 subject identifier, e.g. a SET's `sub_id`
 * @param {Record<string, any>} identity
 * @returns {boolean}
 */
export function subjectMatches(subId, identity) {
  if (!subId || typeof subId !== 'object' || !identity) return false;
  switch (subId.format) {
    case 'iss_sub':
      return typeof subId.sub === 'string' && !!identity.sub && subId.sub === identity.sub
        && (!identity.iss || subId.iss === identity.iss);
    case 'email':
      return identity.email_verified === true && typeof identity.email === 'string' && typeof subId.email === 'string'
        && subId.email.toLowerCase() === identity.email.toLowerCase();
    case 'phone_number':
      return identity.phone_number_verified === true && typeof subId.phone_number === 'string'
        && subId.phone_number === identity.phone_number;
    case 'opaque':
      return opaqueIs(subId, identity.sub);
    case 'account':
      return identity.email_verified === true && typeof identity.email === 'string'
        && subId.uri === `acct:${identity.email}`;
    case 'aliases':
      return (subId.identifiers ?? []).some((id) => subjectMatches(id, identity));
    case 'complex': {
      const members = COMPLEX_MEMBERS.filter((m) => subId[m]);
      if (!members.length) return false;
      return members.every((member) => {
        const value = subId[member];
        if (member === 'user') return subjectMatches(value, identity);
        if (member === 'session') return opaqueIs(value, identity.sid);
        return opaqueIs(value, identity[member]);
      });
    }
    default:
      return false;
  }
}

/** An opaque identifier naming exactly `id`: both must be non-empty strings. */
function opaqueIs(value, id) {
  return value?.format === 'opaque' && typeof value.id === 'string' && value.id !== ''
    && typeof id === 'string' && value.id === id;
}