mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
fix(hub): preserve fresh enrollment authority
This commit is contained in:
@@ -172,6 +172,30 @@ describe("Hub relationship", () => {
|
||||
await secondResult;
|
||||
});
|
||||
|
||||
test("a stale enrollment rejection cannot discard a fresh pending ceremony", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
const expiredConnect = relationship.beginConnect("expired-token");
|
||||
await relationship.enrollmentBegins();
|
||||
|
||||
relationship.holdEnrollment();
|
||||
const freshConnect = relationship.beginConnect("fresh-token");
|
||||
const freshEnrollment = await relationship.enrollmentBegins();
|
||||
relationship.rejectEnrollment(0, 403);
|
||||
await expiredConnect.result;
|
||||
|
||||
expect(relationship.relationshipFile()?.enrollment?.token).toBe("fresh-token");
|
||||
expect(relationship.pendingRelationshipRetries()).toBe(0);
|
||||
|
||||
relationship.completeEnrollment();
|
||||
await freshConnect.result;
|
||||
|
||||
expect(freshEnrollment.token).toBe("fresh-token");
|
||||
expect(relationship.relationshipFile()?.state).toBe("active");
|
||||
expect(relationship.enrollmentAttempts()).toHaveLength(2);
|
||||
expect(relationship.pendingRelationshipRetries()).toBe(0);
|
||||
});
|
||||
|
||||
test("a rejected pending enrollment is discarded without blocking daemon restart", async () => {
|
||||
relationship = await HubRelationshipHarness.start();
|
||||
relationship.holdEnrollment();
|
||||
|
||||
@@ -158,6 +158,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
private socket: HubSocketConnection | null = null;
|
||||
private retry: ScheduledRelationshipTask | null = null;
|
||||
private generation = 0;
|
||||
private enrollmentGeneration = 0;
|
||||
private retryAttempt = 0;
|
||||
private executions: { relationshipId: string; value: HubExecutions } | null = null;
|
||||
|
||||
@@ -176,8 +177,9 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
async start(): Promise<void> {
|
||||
if (this.record?.state === "active") this.openSocket(this.record, false);
|
||||
if (this.record?.state === "pending") {
|
||||
const enrollmentGeneration = this.beginEnrollmentAttempt();
|
||||
try {
|
||||
await this.tryEnrollment(this.record);
|
||||
await this.tryEnrollment(this.record, enrollmentGeneration);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HubEnrollmentRejectedError)) throw error;
|
||||
this.options.logger.warn(
|
||||
@@ -212,8 +214,11 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
throw new Error("A pending Hub enrollment already exists for a different Hub");
|
||||
}
|
||||
this.record = { ...this.record, enrollment: { token: input.token } };
|
||||
const enrollmentGeneration = this.beginEnrollmentAttempt();
|
||||
this.persist(this.record);
|
||||
await this.tryEnrollment(this.record);
|
||||
this.state = "connecting";
|
||||
this.lastError = null;
|
||||
await this.tryEnrollment(this.record, enrollmentGeneration);
|
||||
return this.status();
|
||||
}
|
||||
if (this.record && this.record.state !== "revoked") {
|
||||
@@ -236,7 +241,8 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
this.persist(pending);
|
||||
this.record = pending;
|
||||
this.state = "connecting";
|
||||
await this.tryEnrollment(pending);
|
||||
this.lastError = null;
|
||||
await this.tryEnrollment(pending, this.beginEnrollmentAttempt());
|
||||
return this.status();
|
||||
}
|
||||
|
||||
@@ -271,8 +277,8 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
return { status: this.status() };
|
||||
}
|
||||
|
||||
private async tryEnrollment(pending: PendingRecord): Promise<void> {
|
||||
const generation = this.generation;
|
||||
private async tryEnrollment(pending: PendingRecord, enrollmentGeneration: number): Promise<void> {
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
const verifier = createHash("sha256").update(pending.credential.secret).digest("base64url");
|
||||
try {
|
||||
const enrollment = await this.options.remote.enroll({
|
||||
@@ -285,7 +291,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
credentialVerifier: verifier,
|
||||
scopes: pending.relationship.scopes,
|
||||
});
|
||||
if (generation !== this.generation) return;
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
if (
|
||||
enrollment.relationshipId !== pending.relationship.id ||
|
||||
!enrollment.scopes.includes("hub.*")
|
||||
@@ -301,16 +307,17 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
};
|
||||
this.persist(active);
|
||||
this.record = active;
|
||||
this.retry = null;
|
||||
this.retryAttempt = 0;
|
||||
this.openSocket(active, false);
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return;
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
if (error instanceof HubEnrollmentRejectedError) {
|
||||
this.remove();
|
||||
throw error;
|
||||
}
|
||||
this.lastError = error instanceof Error ? error.message : String(error);
|
||||
this.scheduleEnrollment(pending);
|
||||
this.scheduleEnrollment(pending, enrollmentGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,9 +387,15 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
this.schedule(() => this.openSocket(record, true));
|
||||
}
|
||||
|
||||
private scheduleEnrollment(record: PendingRecord): void {
|
||||
private scheduleEnrollment(record: PendingRecord, enrollmentGeneration: number): void {
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
this.state = "reconnecting";
|
||||
this.schedule(() => void this.tryEnrollment(record));
|
||||
this.retry?.cancel();
|
||||
const delay = this.retryPolicy.delay(this.retryAttempt++);
|
||||
this.retry = this.clock.schedule(delay, () => {
|
||||
if (enrollmentGeneration !== this.enrollmentGeneration) return;
|
||||
void this.tryEnrollment(record, enrollmentGeneration);
|
||||
});
|
||||
}
|
||||
|
||||
private async tryRevocation(record: DisconnectingRecord): Promise<void> {
|
||||
@@ -435,10 +448,18 @@ export class HubRelationshipController implements HubRelationshipManagement {
|
||||
|
||||
private cancelLifecycle(): void {
|
||||
++this.generation;
|
||||
++this.enrollmentGeneration;
|
||||
this.retry?.cancel();
|
||||
this.retry = null;
|
||||
}
|
||||
|
||||
private beginEnrollmentAttempt(): number {
|
||||
this.retry?.cancel();
|
||||
this.retry = null;
|
||||
this.retryAttempt = 0;
|
||||
return ++this.enrollmentGeneration;
|
||||
}
|
||||
|
||||
private persist(record: HubRelationshipRecord): void {
|
||||
writePrivateFileAtomicSync(this.filePath, `${JSON.stringify(record, null, 2)}\n`);
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ class TestRelationshipClock implements HubRelationshipClock, HubRelationshipRetr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingTasks(): number {
|
||||
return this.tasks.filter((task) => !task.cancelled).length;
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryHubSocket extends EventEmitter implements WebSocketLike, HubSocketConnection {
|
||||
@@ -307,7 +311,11 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
|
||||
enrollments: HubEnrollment[] = [];
|
||||
revocations: HubRevocation[] = [];
|
||||
sockets: SocketAttempt[] = [];
|
||||
private enrollmentGate: Deferred<HubEnrollmentResult> | null = null;
|
||||
private readonly enrollmentGates: Array<Deferred<HubEnrollmentResult>> = [];
|
||||
private readonly heldEnrollments: Array<{
|
||||
input: HubEnrollment;
|
||||
gate: Deferred<HubEnrollmentResult>;
|
||||
}> = [];
|
||||
private enrollmentObserved = deferred<void>();
|
||||
private socketObserved = deferred<void>();
|
||||
private enrollmentRejection: 401 | 403 | null = null;
|
||||
@@ -319,7 +327,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
|
||||
constructor(private readonly captureRelationship: () => RelationshipInvocationSnapshot) {}
|
||||
|
||||
holdEnrollment(): void {
|
||||
this.enrollmentGate = deferred<HubEnrollmentResult>();
|
||||
this.enrollmentGates.push(deferred<HubEnrollmentResult>());
|
||||
}
|
||||
|
||||
rejectNextEnrollment(statusCode: 401 | 403): void {
|
||||
@@ -336,21 +344,33 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
|
||||
this.enrollmentRejection = null;
|
||||
throw new HubEnrollmentRejectedError(statusCode);
|
||||
}
|
||||
if (this.enrollmentGate) return this.enrollmentGate.promise;
|
||||
const gate = this.enrollmentGates.shift();
|
||||
if (gate) {
|
||||
this.heldEnrollments.push({ input, gate });
|
||||
return gate.promise;
|
||||
}
|
||||
return this.enrollmentResult(input);
|
||||
}
|
||||
|
||||
completeEnrollment(): void {
|
||||
const input = this.enrollments.at(-1);
|
||||
if (!input || !this.enrollmentGate) throw new Error("No enrollment is waiting");
|
||||
this.enrollmentGate.resolve(this.enrollmentResult(input));
|
||||
this.enrollmentGate = null;
|
||||
const held = this.heldEnrollments.at(-1);
|
||||
if (!held) throw new Error("No enrollment is waiting");
|
||||
held.gate.resolve(this.enrollmentResult(held.input));
|
||||
this.heldEnrollments.splice(this.heldEnrollments.indexOf(held), 1);
|
||||
}
|
||||
|
||||
loseEnrollmentResponse(): void {
|
||||
if (!this.enrollmentGate) throw new Error("No enrollment is waiting");
|
||||
this.enrollmentGate.reject(new Error("Enrollment response was lost"));
|
||||
this.enrollmentGate = null;
|
||||
const held = this.heldEnrollments.at(-1);
|
||||
if (!held) throw new Error("No enrollment is waiting");
|
||||
held.gate.reject(new Error("Enrollment response was lost"));
|
||||
this.heldEnrollments.splice(this.heldEnrollments.indexOf(held), 1);
|
||||
}
|
||||
|
||||
rejectEnrollment(index: number, statusCode: 401 | 403): void {
|
||||
const held = this.heldEnrollments[index];
|
||||
if (!held) throw new Error(`Enrollment ${index} is not waiting`);
|
||||
held.gate.reject(new HubEnrollmentRejectedError(statusCode));
|
||||
this.heldEnrollments.splice(index, 1);
|
||||
}
|
||||
|
||||
async enrollmentAt(index: number): Promise<HubEnrollment> {
|
||||
@@ -535,6 +555,14 @@ export class HubRelationshipHarness {
|
||||
this.remote.loseEnrollmentResponse();
|
||||
}
|
||||
|
||||
rejectEnrollment(index: number, statusCode: 401 | 403): void {
|
||||
this.remote.rejectEnrollment(index, statusCode);
|
||||
}
|
||||
|
||||
pendingRelationshipRetries(): number {
|
||||
return this.clock.pendingTasks();
|
||||
}
|
||||
|
||||
rejectNextEnrollment(statusCode: 401 | 403): void {
|
||||
this.remote.rejectNextEnrollment(statusCode);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user