Bind auth completions to their attempt handles
This commit is contained in:
parent
b9ab8c0022
commit
f2eec21c57
3 changed files with 41 additions and 7 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"$id": "heimdall.auth_completion_receipt.v1",
|
"$id": "heimdall.auth_completion_receipt.v1",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["schema", "status"],
|
"required": ["schema", "status", "handle"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"schema": { "const": "heimdall.auth_completion_receipt.v1" },
|
"schema": { "const": "heimdall.auth_completion_receipt.v1" },
|
||||||
"status": { "enum": ["pending", "authenticated", "denied"] },
|
"status": { "enum": ["pending", "authenticated", "denied"] },
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ async function refreshAuth(
|
||||||
const refreshedPayload = refreshed.json<Record<string, unknown>>();
|
const refreshedPayload = refreshed.json<Record<string, unknown>>();
|
||||||
if (refreshed.statusCode !== 201) {
|
if (refreshed.statusCode !== 201) {
|
||||||
return {
|
return {
|
||||||
status: "denied",
|
status: "accepted",
|
||||||
payloadSchema: "heimdall.auth_refresh_receipt.v1",
|
payloadSchema: "heimdall.auth_refresh_receipt.v1",
|
||||||
payload: {
|
payload: {
|
||||||
schema: "heimdall.auth_refresh_receipt.v1",
|
schema: "heimdall.auth_refresh_receipt.v1",
|
||||||
|
|
@ -326,22 +326,29 @@ async function completeAuth(
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
if (attempt.expiresAt <= now && attempt.status !== "completed") {
|
if (attempt.expiresAt <= now && attempt.status !== "completed") {
|
||||||
await store.updateAuthAttempt(appSlug, handle, { status: "expired", at: now });
|
await store.updateAuthAttempt(appSlug, handle, { status: "expired", at: now });
|
||||||
return authCompletion("denied", { error: "expired_attempt" });
|
return authCompletion("denied", { handle, error: "expired_attempt" });
|
||||||
}
|
}
|
||||||
if (attempt.status === "pending") return authCompletion("pending", { handle });
|
if (attempt.status === "pending") return authCompletion("pending", { handle });
|
||||||
if (attempt.status !== "completed") return authCompletion("denied", { error: attempt.denialCode ?? `attempt_${attempt.status}` });
|
if (attempt.status !== "completed") {
|
||||||
|
return authCompletion("denied", { handle, error: attempt.denialCode ?? `attempt_${attempt.status}` });
|
||||||
|
}
|
||||||
const redemption = await app.inject({
|
const redemption = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/v1/apps/${appSlug}/auth-completions/redeem`,
|
url: `/v1/apps/${appSlug}/auth-completions/redeem`,
|
||||||
payload: { completionCode: handle },
|
payload: { completionCode: handle },
|
||||||
});
|
});
|
||||||
if (redemption.statusCode !== 201) return authCompletion("denied", { error: "invalid_or_expired_completion" });
|
if (redemption.statusCode !== 201) {
|
||||||
return authCompletion("authenticated", redemption.json<Record<string, unknown>>());
|
return authCompletion("denied", { handle, error: "invalid_or_expired_completion" });
|
||||||
|
}
|
||||||
|
return authCompletion("authenticated", { handle, ...redemption.json<Record<string, unknown>>() });
|
||||||
}
|
}
|
||||||
|
|
||||||
function authCompletion(status: string, values: Record<string, unknown>) {
|
function authCompletion(status: string, values: Record<string, unknown>) {
|
||||||
return {
|
return {
|
||||||
status: status === "authenticated" ? "accepted" : status,
|
// CultNet reports whether the private command executed. Authentication state
|
||||||
|
// remains inside the typed receipt so callers can decode pending and denied
|
||||||
|
// outcomes instead of mistaking them for transport failures.
|
||||||
|
status: "accepted",
|
||||||
payloadSchema: "heimdall.auth_completion_receipt.v1",
|
payloadSchema: "heimdall.auth_completion_receipt.v1",
|
||||||
payload: { ...values, schema: "heimdall.auth_completion_receipt.v1", status },
|
payload: { ...values, schema: "heimdall.auth_completion_receipt.v1", status },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,19 @@ describe("Heimdall Eve access plugin", () => {
|
||||||
const state = new URL(navigation.url).searchParams.get("state");
|
const state = new URL(navigation.url).searchParams.get("state");
|
||||||
expect(state).toBeTruthy();
|
expect(state).toBeTruthy();
|
||||||
|
|
||||||
|
const pendingEnvelope = sealPrivateEnvelope({
|
||||||
|
appSlug: "ghostlight",
|
||||||
|
operation: "heimdall.auth.complete",
|
||||||
|
contentSchema: "heimdall.auth_complete_command.v1",
|
||||||
|
idempotencyKey: "complete-pending-1",
|
||||||
|
secret,
|
||||||
|
payload: { handle },
|
||||||
|
});
|
||||||
|
const pendingResponse = await invoke(plane.endpoint, "heimdall.auth.complete", "transport-complete-pending-1", pendingEnvelope);
|
||||||
|
expect(pendingResponse.status).toBe("accepted");
|
||||||
|
const pending = openPrivateEnvelope(decodeEnvelope(pendingResponse.payload), secret);
|
||||||
|
expect(pending).toMatchObject({ status: "pending", handle });
|
||||||
|
|
||||||
const callback = await app.inject({
|
const callback = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`,
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(state ?? "")}`,
|
||||||
|
|
@ -134,8 +147,22 @@ describe("Heimdall Eve access plugin", () => {
|
||||||
expect(completionWire.contentSchema).toBe("heimdall.auth_completion_receipt.v1");
|
expect(completionWire.contentSchema).toBe("heimdall.auth_completion_receipt.v1");
|
||||||
const completion = openPrivateEnvelope(completionWire, secret);
|
const completion = openPrivateEnvelope(completionWire, secret);
|
||||||
expect(completion.status).toBe("authenticated");
|
expect(completion.status).toBe("authenticated");
|
||||||
|
expect(completion.handle).toBe(handle);
|
||||||
expect(completion.accessToken).toEqual(expect.any(String));
|
expect(completion.accessToken).toEqual(expect.any(String));
|
||||||
|
|
||||||
|
const consumedEnvelope = sealPrivateEnvelope({
|
||||||
|
appSlug: "ghostlight",
|
||||||
|
operation: "heimdall.auth.complete",
|
||||||
|
contentSchema: "heimdall.auth_complete_command.v1",
|
||||||
|
idempotencyKey: "complete-consumed-1",
|
||||||
|
secret,
|
||||||
|
payload: { handle },
|
||||||
|
});
|
||||||
|
const consumedResponse = await invoke(plane.endpoint, "heimdall.auth.complete", "transport-complete-consumed-1", consumedEnvelope);
|
||||||
|
expect(consumedResponse.status).toBe("accepted");
|
||||||
|
const consumed = openPrivateEnvelope(decodeEnvelope(consumedResponse.payload), secret);
|
||||||
|
expect(consumed).toMatchObject({ status: "denied", handle, error: "attempt_consumed" });
|
||||||
|
|
||||||
const refreshEnvelope = sealPrivateEnvelope({
|
const refreshEnvelope = sealPrivateEnvelope({
|
||||||
appSlug: "ghostlight",
|
appSlug: "ghostlight",
|
||||||
operation: "heimdall.auth.refresh",
|
operation: "heimdall.auth.refresh",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue