src/issuer/lifecycle/status/status-list.service.ts
Properties |
|
Methods |
|
constructor(configService: ConfigService, certService: CertService, keyService: KeyService, statusMappingRepository: Repository<StatusMapping>, statusListRepository: Repository<StatusListEntity>, tenantRepository: Repository<TenantEntity>, configImportService: ConfigImportService, logger: PinoLogger, statusListConfigService: StatusListConfigService, configImportOrchestrator: ConfigImportOrchestratorService)
|
|||||||||||||||||||||||||||||||||
|
Parameters :
|
| Private buildAggregationUri | ||||||
buildAggregationUri(tenantId: string)
|
||||||
|
Build the aggregation URI for a tenant. This endpoint returns all status list URIs for the tenant. See RFC draft-ietf-oauth-status-list Section 9.
Parameters :
Returns :
string
|
| Private buildStatusListUri |
buildStatusListUri(tenantId: string, listId: string)
|
|
Build the URI for a status list.
Returns :
string
|
| Async createEntry | ||||||||||||
createEntry(session: Session, credentialConfigurationId: string)
|
||||||||||||
|
Get the next free entry in the status list. Automatically creates a new list if no available list is found.
Parameters :
Returns :
Promise<JWTwithStatusListPayload>
The status list payload to include in the credential. |
| Async createListJWT | ||||||
createListJWT(entry: StatusListEntity)
|
||||||
|
Create the JWT for a status list and update the entity. The JWT includes:
Parameters :
Returns :
Promise<void>
|
| Async createNewList | ||||||||||||
createNewList(tenantId: string, options?: literal type)
|
||||||||||||
|
Create a new status list, optionally bound to a specific credential configuration and/or certificate.
Parameters :
Returns :
Promise<StatusListEntity>
The created status list entity |
| Async deleteList | ||||||||||||
deleteList(tenantId: string, listId: string)
|
||||||||||||
|
Delete a status list by ID. Only allows deletion if the list has no used entries.
Parameters :
Returns :
Promise<void>
|
| Private Async findAvailableList | ||||||||||||
findAvailableList(tenantId: string, credentialConfigurationId?: string)
|
||||||||||||
|
Find an available status list with free entries. Priority: dedicated list for the credential config > shared lists
Parameters :
Returns :
Promise<StatusListEntity | null>
The available list or null if none found. |
| Private Async getEffectiveBits | ||||||
getEffectiveBits(tenantId: string)
|
||||||
|
Get the effective bits per status for a tenant.
Parameters :
Returns :
Promise<BitsPerStatus>
|
| Private Async getEffectiveCapacity | ||||||
getEffectiveCapacity(tenantId: string)
|
||||||
|
Get the effective status list capacity for a tenant.
Parameters :
Returns :
Promise<number>
|
| Async getListById | ||||||||||||
getListById(tenantId: string, listId: string)
|
||||||||||||
|
Get a specific status list by ID.
Parameters :
Returns :
Promise<StatusListEntity>
The status list entity. |
| Async getListJwt | ||||||||||||
getListJwt(tenantId: string, listId: string)
|
||||||||||||
|
Get the JWT for a specific status list.
Parameters :
Returns :
Promise<string>
The JWT for the status list. |
| Async getLists | ||||||||
getLists(tenantId: string)
|
||||||||
|
Get all status lists for a tenant.
Parameters :
Returns :
Promise<StatusListEntity[]>
Array of status lists. |
| Async getStatusListUris | ||||||||
getStatusListUris(tenantId: string)
|
||||||||
|
Get all status list URIs for a tenant. Used for the status list aggregation endpoint (RFC Section 9.3).
Parameters :
Returns :
Promise<string[]>
Array of status list URIs. |
| Async hasStillFreeEntries | ||||||||||||
hasStillFreeEntries(tenantId: string, credentialConfigurationId?: string)
|
||||||||||||
|
Check if there are still free entries available for a credential configuration.
Parameters :
Returns :
Promise<boolean>
True if there are free entries. |
| Async importForTenant | ||||||
importForTenant(tenantId: string)
|
||||||
|
Import status list configurations for a specific tenant.
Parameters :
Returns :
Promise<void>
|
| Private Async processStatusListConfig | |||||||||
processStatusListConfig(tenantId: string, config: StatusListImportDto)
|
|||||||||
|
Process a status list config for import.
Parameters :
Returns :
any
|
| Private Async setEntry | ||||||||||||||||||||
setEntry(listId: string, index: number, value: number, tenantId: string)
|
||||||||||||||||||||
|
Update the value of an entry in a specific status list.
JWT regeneration depends on the tenant's
Parameters :
Returns :
Promise<void>
|
| Private shuffleArray | ||||||
shuffleArray(array: T[])
|
||||||
Type parameters :
|
||||||
|
Cryptographically secure Fisher-Yates shuffle
Parameters :
Returns :
T[]
|
| Async updateList | ||||||||||||||||
updateList(tenantId: string, listId: string, updates: literal type)
|
||||||||||||||||
|
Update a status list's configuration (credential binding and/or certificate).
Parameters :
Returns :
Promise<StatusListEntity>
|
| Async updateStatus | ||||||||||||
updateStatus(value: StatusUpdateDto, tenantId: string)
|
||||||||||||
|
Update the status of a session and its credential configuration.
Parameters :
Returns :
Promise<void>
|
| Public Readonly keyService |
Type : KeyService
|
Decorators :
@Inject('KeyService')
|
import { randomInt } from "node:crypto";
import {
ConflictException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import {
BitsPerStatus,
createHeaderAndPayload,
JWTwithStatusListPayload,
StatusList,
StatusListJWTHeaderParameters,
} from "@sd-jwt/jwt-status-list";
import { JwtPayload } from "@sd-jwt/types";
import { PinoLogger } from "nestjs-pino";
import { IsNull, Repository } from "typeorm";
import { v4 } from "uuid";
import { TenantEntity } from "../../../auth/tenant/entitites/tenant.entity";
import { CertService } from "../../../crypto/key/cert/cert.service";
import { CertUsage } from "../../../crypto/key/entities/cert-usage.entity";
import { KeyService } from "../../../crypto/key/key.service";
import { Session } from "../../../session/entities/session.entity";
import { ConfigImportService } from "../../../shared/utils/config-import/config-import.service";
import {
ConfigImportOrchestratorService,
ImportPhase,
} from "../../../shared/utils/config-import/config-import-orchestrator.service";
import { StatusListImportDto } from "./dto/status-list-import.dto";
import { StatusUpdateDto } from "./dto/status-update.dto";
import { StatusListEntity } from "./entities/status-list.entity";
import { StatusMapping } from "./entities/status-mapping.entity";
import { StatusListConfigService } from "./status-list-config.service";
@Injectable()
export class StatusListService {
constructor(
private readonly configService: ConfigService,
private readonly certService: CertService,
@Inject("KeyService") public readonly keyService: KeyService,
@InjectRepository(StatusMapping)
private readonly statusMappingRepository: Repository<StatusMapping>,
@InjectRepository(StatusListEntity)
private readonly statusListRepository: Repository<StatusListEntity>,
@InjectRepository(TenantEntity)
private readonly tenantRepository: Repository<TenantEntity>,
private readonly configImportService: ConfigImportService,
private readonly logger: PinoLogger,
@Inject(forwardRef(() => StatusListConfigService))
private readonly statusListConfigService: StatusListConfigService,
readonly configImportOrchestrator: ConfigImportOrchestratorService,
) {
configImportOrchestrator.register(
"status-lists",
ImportPhase.FINAL,
(tenantId) => this.importForTenant(tenantId),
);
}
/**
* Get the effective status list capacity for a tenant.
*/
private async getEffectiveCapacity(tenantId: string): Promise<number> {
const tenant = await this.tenantRepository.findOneBy({ id: tenantId });
return (
tenant?.statusListConfig?.capacity ??
this.configService.getOrThrow<number>("STATUS_CAPACITY")
);
}
/**
* Get the effective bits per status for a tenant.
*/
private async getEffectiveBits(tenantId: string): Promise<BitsPerStatus> {
const tenant = await this.tenantRepository.findOneBy({ id: tenantId });
return (
tenant?.statusListConfig?.bits ??
this.configService.getOrThrow<BitsPerStatus>("STATUS_BITS")
);
}
/**
* Cryptographically secure Fisher-Yates shuffle
*/
private shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = randomInt(0, i + 1);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
/**
* Build the URI for a status list.
*/
private buildStatusListUri(tenantId: string, listId: string): string {
const baseUrl = this.configService.getOrThrow<string>("PUBLIC_URL");
return `${baseUrl}/${tenantId}/status-management/status-list/${listId}`;
}
/**
* Build the aggregation URI for a tenant.
* This endpoint returns all status list URIs for the tenant.
* See RFC draft-ietf-oauth-status-list Section 9.
*/
private buildAggregationUri(tenantId: string): string {
const baseUrl = this.configService.getOrThrow<string>("PUBLIC_URL");
return `${baseUrl}/${tenantId}/status-management/status-list-aggregation`;
}
/**
* Create a new status list, optionally bound to a specific credential configuration and/or certificate.
* @param tenantId The tenant ID
* @param options Optional configuration for the new list
* @returns The created status list entity
*/
async createNewList(
tenantId: string,
options?: {
credentialConfigurationId?: string;
certId?: string;
bits?: BitsPerStatus;
capacity?: number;
},
): Promise<StatusListEntity> {
const size =
options?.capacity ?? (await this.getEffectiveCapacity(tenantId));
// create an empty array with the size
const elements = new Array(size).fill(0).map(() => 0);
// create a list of indexes and shuffle them using crypto-secure randomness
const stack = this.shuffleArray(
new Array(size).fill(0).map((_, i) => i),
);
const bits = options?.bits ?? (await this.getEffectiveBits(tenantId));
// Validate certId if provided
if (options?.certId) {
const cert = await this.certService.find({
tenantId,
type: CertUsage.StatusList,
id: options.certId,
});
if (!cert) {
throw new NotFoundException(
`Certificate ${options.certId} not found for tenant ${tenantId}`,
);
}
}
const entry = await this.statusListRepository.save({
id: v4(),
tenantId,
credentialConfigurationId:
options?.credentialConfigurationId ?? null,
certId: options?.certId ?? null,
elements,
stack,
bits,
});
await this.createListJWT(entry);
return entry;
}
/**
* Create the JWT for a status list and update the entity.
* The JWT includes:
* - `iat`: When the token was issued (REQUIRED)
* - `exp`: When the token expires (RECOMMENDED)
* - `ttl`: How long verifiers can cache before fetching fresh copy (RECOMMENDED)
* - `aggregation_uri`: URI to fetch all status list URIs (OPTIONAL, per RFC Section 9)
*/
async createListJWT(entry: StatusListEntity): Promise<void> {
const list = new StatusList(entry.elements, entry.bits);
const iss = `${this.configService.getOrThrow<string>("PUBLIC_URL")}`;
const sub = this.buildStatusListUri(entry.tenantId, entry.id);
// Get TTL from tenant config or global default
const effectiveConfig =
await this.statusListConfigService.getEffectiveConfig(
entry.tenantId,
);
const ttl = effectiveConfig.ttl!;
const now = Math.floor(Date.now() / 1000);
const exp = now + ttl;
const prePayload: JwtPayload = {
iss,
sub,
iat: now,
exp,
ttl, // Maximum cache time in seconds for verifiers
};
// Use the pinned certificate if specified, otherwise use the tenant's default status list cert
const cert = entry.certId
? await this.certService.find({
tenantId: entry.tenantId,
type: CertUsage.StatusList,
id: entry.certId,
})
: await this.certService.find({
tenantId: entry.tenantId,
type: CertUsage.StatusList,
});
if (!cert) {
throw new NotFoundException(
`Certificate ${entry.certId} not found for tenant ${entry.tenantId}`,
);
}
const preHeader: StatusListJWTHeaderParameters = {
alg: "ES256",
typ: "statuslist+jwt",
x5c: this.certService.getCertChain(cert),
};
const { header, payload } = createHeaderAndPayload(
list,
prePayload,
preHeader,
);
// Add aggregation_uri to status_list if enabled for this tenant (RFC Section 9.2)
// This allows relying parties to pre-fetch all status lists for offline validation
if (effectiveConfig.enableAggregation && payload.status_list) {
(payload.status_list as Record<string, unknown>).aggregation_uri =
this.buildAggregationUri(entry.tenantId);
}
const jwt = await this.keyService.signJWT(
payload,
header,
entry.tenantId,
cert.keyId,
);
// Store JWT and expiration time
const expiresAt = new Date(exp * 1000);
await this.statusListRepository.update(
{ id: entry.id, tenantId: entry.tenantId },
{ jwt, expiresAt },
);
}
/**
* Get all status lists for a tenant.
* @param tenantId The ID of the tenant.
* @returns Array of status lists.
*/
async getLists(tenantId: string): Promise<StatusListEntity[]> {
return this.statusListRepository.find({
where: { tenantId },
order: { createdAt: "ASC" },
});
}
/**
* Get all status list URIs for a tenant.
* Used for the status list aggregation endpoint (RFC Section 9.3).
* @param tenantId The ID of the tenant.
* @returns Array of status list URIs.
*/
async getStatusListUris(tenantId: string): Promise<string[]> {
const lists = await this.getLists(tenantId);
return lists.map((list) => this.buildStatusListUri(tenantId, list.id));
}
/**
* Get a specific status list by ID.
* @param tenantId The ID of the tenant.
* @param listId The ID of the status list.
* @returns The status list entity.
*/
async getListById(
tenantId: string,
listId: string,
): Promise<StatusListEntity> {
const list = await this.statusListRepository.findOneBy({
id: listId,
tenantId,
});
if (!list) {
throw new NotFoundException(`Status list ${listId} not found`);
}
return list;
}
/**
* Get the JWT for a specific status list.
* @param tenantId The ID of the tenant.
* @param listId The ID of the status list.
* @returns The JWT for the status list.
*/
async getListJwt(tenantId: string, listId: string): Promise<string> {
let list = await this.getListById(tenantId, listId);
// Check if JWT needs regeneration (expired or missing)
const needsRegeneration =
!list.jwt || !list.expiresAt || list.expiresAt <= new Date();
if (needsRegeneration) {
await this.createListJWT(list);
// Reload to get the updated JWT
list = await this.getListById(tenantId, listId);
}
return list.jwt!;
}
/**
* Check if there are still free entries available for a credential configuration.
* @param tenantId The tenant ID.
* @param credentialConfigurationId The credential configuration ID.
* @returns True if there are free entries.
*/
async hasStillFreeEntries(
tenantId: string,
credentialConfigurationId?: string,
): Promise<boolean> {
// Check for dedicated list first, then shared lists
const list = await this.findAvailableList(
tenantId,
credentialConfigurationId,
);
return list !== null;
}
/**
* Find an available status list with free entries.
* Priority: dedicated list for the credential config > shared lists
* @param tenantId The tenant ID.
* @param credentialConfigurationId Optional credential config ID.
* @returns The available list or null if none found.
*/
private async findAvailableList(
tenantId: string,
credentialConfigurationId?: string,
): Promise<StatusListEntity | null> {
// First, try to find a dedicated list for this credential config with free entries
if (credentialConfigurationId) {
const dedicatedList = await this.statusListRepository.findOne({
where: {
tenantId,
credentialConfigurationId,
// TypeORM doesn't support array length checks directly,
// so we'll filter after fetching
},
order: { createdAt: "ASC" },
});
if (dedicatedList && dedicatedList.stack.length > 0) {
return dedicatedList;
}
}
// Then, try to find any shared list (credentialConfigurationId is null) with free entries
const sharedLists = await this.statusListRepository.find({
where: {
tenantId,
credentialConfigurationId: IsNull(),
},
order: { createdAt: "ASC" },
});
for (const list of sharedLists) {
if (list.stack.length > 0) {
return list;
}
}
return null;
}
/**
* Get the next free entry in the status list.
* Automatically creates a new list if no available list is found.
* @param session The session for which to create the entry.
* @param credentialConfigurationId The credential configuration ID.
* @returns The status list payload to include in the credential.
*/
async createEntry(
session: Session,
credentialConfigurationId: string,
): Promise<JWTwithStatusListPayload> {
// Find an available list or create a new one
// If no available list found, create a new shared list
// (dedicated lists must be created explicitly via the API)
const list =
(await this.findAvailableList(
session.tenantId,
credentialConfigurationId,
)) ?? (await this.createNewList(session.tenantId));
// Pop an index from the stack
const idx = list.stack.pop();
if (idx === undefined) {
// This shouldn't happen since we just checked, but handle it gracefully
throw new ConflictException(
"No free entries available in any status list",
);
}
// Save the updated stack
await this.statusListRepository.update(
{ id: list.id },
{ stack: list.stack },
);
const uri = this.buildStatusListUri(session.tenantId, list.id);
// Store the index in the status mapping
await this.statusMappingRepository.save({
tenantId: session.tenantId,
sessionId: session.id,
statusListId: list.id,
index: idx,
list: uri,
credentialConfigurationId,
});
return {
status: {
status_list: {
idx,
uri,
},
},
};
}
/**
* Update the value of an entry in a specific status list.
* JWT regeneration depends on the tenant's `immediateUpdate` setting:
* - If true: JWT is regenerated immediately
* - If false (default): JWT is only regenerated on next request when TTL expires
* @param listId The ID of the status list.
* @param index The index in the status list.
* @param value The new status value.
* @param tenantId The tenant ID.
*/
private async setEntry(
listId: string,
index: number,
value: number,
tenantId: string,
): Promise<void> {
const entry = await this.getListById(tenantId, listId);
entry.elements[index] = value;
await this.statusListRepository.update(
{ id: listId },
{ elements: entry.elements },
);
// Check if immediate JWT regeneration is enabled
const effectiveConfig =
await this.statusListConfigService.getEffectiveConfig(tenantId);
if (effectiveConfig.immediateUpdate) {
await this.createListJWT(entry);
}
}
/**
* Update the status of a session and its credential configuration.
* @param value The status update DTO.
* @param tenantId The tenant ID.
*/
async updateStatus(
value: StatusUpdateDto,
tenantId: string,
): Promise<void> {
const entries = await this.statusMappingRepository.findBy({
tenantId,
sessionId: value.sessionId,
credentialConfigurationId: value.credentialConfigurationId,
});
if (entries.length === 0) {
throw new ConflictException(
`No status mapping found for session ${value.sessionId} and credential configuration ${value.credentialConfigurationId}`,
);
}
for (const entry of entries) {
await this.setEntry(
entry.statusListId,
entry.index,
value.status,
tenantId,
);
}
}
/**
* Delete a status list by ID.
* Only allows deletion if the list has no used entries.
* @param tenantId The tenant ID.
* @param listId The status list ID.
*/
async deleteList(tenantId: string, listId: string): Promise<void> {
// Verify the list exists (throws NotFoundException if not)
await this.getListById(tenantId, listId);
// Check if any entries are in use (mappings exist)
const mappingsCount = await this.statusMappingRepository.countBy({
tenantId,
statusListId: listId,
});
if (mappingsCount > 0) {
throw new ConflictException(
`Cannot delete status list ${listId}: ${mappingsCount} credentials are using it`,
);
}
await this.statusListRepository.delete({ id: listId, tenantId });
}
/**
* Update a status list's configuration (credential binding and/or certificate).
* @param tenantId The tenant ID.
* @param listId The status list ID.
* @param updates The updates to apply.
*/
async updateList(
tenantId: string,
listId: string,
updates: {
credentialConfigurationId?: string | null;
certId?: string | null;
},
): Promise<StatusListEntity> {
const list = await this.getListById(tenantId, listId);
// Validate new certId if provided
if (updates.certId !== undefined && updates.certId !== null) {
const cert = await this.certService.find({
tenantId,
type: CertUsage.StatusList,
id: updates.certId,
});
if (!cert) {
throw new NotFoundException(
`Certificate ${updates.certId} not found for tenant ${tenantId}`,
);
}
}
let needsJwtRegeneration = false;
if (updates.credentialConfigurationId !== undefined) {
list.credentialConfigurationId = updates.credentialConfigurationId;
}
if (updates.certId !== undefined) {
list.certId = updates.certId;
needsJwtRegeneration = true;
}
const savedList = await this.statusListRepository.save(list);
// Regenerate JWT if the certificate changed
if (needsJwtRegeneration) {
await this.createListJWT(savedList);
// Reload to get the updated JWT
return this.getListById(tenantId, listId);
}
return savedList;
}
/**
* Import status list configurations for a specific tenant.
*/
async importForTenant(tenantId: string): Promise<void> {
await this.configImportService.importConfigsForTenant<StatusListImportDto>(
tenantId,
{
subfolder: "issuance/status-lists",
fileExtension: ".json",
validationClass: StatusListImportDto,
resourceType: "status list",
checkExists: async (tid, data) => {
// Check if a list with this ID already exists
const existing = await this.statusListRepository.findOneBy({
id: data.id,
tenantId: tid,
});
return existing !== null;
},
deleteExisting: async (tid, data) => {
// Check if the list has any mappings before deleting
const mappingsCount =
await this.statusMappingRepository.countBy({
tenantId: tid,
statusListId: data.id,
});
if (mappingsCount > 0) {
this.logger.warn(
`[${tid}] Cannot reimport status list ${data.id}: ${mappingsCount} credentials are using it`,
);
return;
}
await this.statusListRepository.delete({
id: data.id,
tenantId: tid,
});
},
processItem: async (tid, config) => {
await this.processStatusListConfig(tid, config);
},
},
);
}
/**
* Process a status list config for import.
*/
private async processStatusListConfig(
tenantId: string,
config: StatusListImportDto,
) {
// Get effective size and bits (from config, tenant defaults, or global defaults)
const size =
config.capacity ?? (await this.getEffectiveCapacity(tenantId));
const bits = config.bits ?? (await this.getEffectiveBits(tenantId));
// Create the shuffled stack
const elements = new Array(size).fill(0).map(() => 0);
const stack = this.shuffleArray(
new Array(size).fill(0).map((_, i) => i),
);
// Validate certId if provided
if (config.certId) {
const cert = await this.certService.find({
tenantId,
type: CertUsage.StatusList,
id: config.certId,
});
if (!cert) {
throw new Error(
`Certificate ${config.certId} not found for tenant ${tenantId}`,
);
}
}
// Save with the provided ID
const entry = await this.statusListRepository.save({
id: config.id,
tenantId,
credentialConfigurationId: config.credentialConfigurationId ?? null,
certId: config.certId ?? null,
elements,
stack,
bits,
});
// Generate the JWT
await this.createListJWT(entry);
}
}