Bind Ghostlight logout to exact session custody
This commit is contained in:
parent
5d777245ea
commit
9065dfa43b
16 changed files with 221 additions and 14 deletions
|
|
@ -3,7 +3,7 @@
|
|||
Ghostlight Dungeon uses Heimdall's private CultNet authentication commands and
|
||||
the `gamecult.heimdall.access` Eve plugin. Heimdall owns provider OAuth,
|
||||
persistent attempts, local account identity, entitlement evaluation, signed
|
||||
access claims, completion redemption, and refresh. Ghostlight verifies claims
|
||||
access claims, completion redemption, refresh, and exact-session revocation. Ghostlight verifies claims
|
||||
locally and owns app sessions, campaigns, campaign membership, current campaign
|
||||
selection, and every world mutation.
|
||||
|
||||
|
|
@ -26,3 +26,7 @@ The browser never carries provider tokens or a completion code. The access
|
|||
plugin stores only an opaque attempt handle. Ghostlight redeems that handle over
|
||||
the encrypted loopback command plane, verifies the resulting `aud=ghostlight`
|
||||
claim against Heimdall JWKS, and establishes app-local campaign authority.
|
||||
Short-lived local access continues without a Heimdall round trip. Scheduled
|
||||
refresh re-evaluates the Ghostlight-owned KLTST role policy; explicit logout
|
||||
revokes both Ghostlight's local session and the matching Heimdall refresh
|
||||
custody record.
|
||||
|
|
|
|||
|
|
@ -219,9 +219,13 @@ Landed in the first pass:
|
|||
|
||||
Next in this phase:
|
||||
|
||||
- revocation flows
|
||||
- connection lifecycle and token-rotation work
|
||||
|
||||
Landed for the Ghostlight private-command consumer:
|
||||
|
||||
- exact persisted-session validation for refresh
|
||||
- revision-bound logout revocation with stale-refresh rejection
|
||||
|
||||
### Phase 3: Repixelizer binding
|
||||
|
||||
Landed in Repixelizer:
|
||||
|
|
|
|||
|
|
@ -595,7 +595,6 @@ Examples:
|
|||
|
||||
The following are not landed yet:
|
||||
|
||||
- revocation flows
|
||||
- admin/grant management surfaces
|
||||
- GitHub callback runtime
|
||||
- redacted per-account auth witness export and Odin/CultMesh consumption beyond
|
||||
|
|
@ -608,10 +607,13 @@ The live daemon also binds a loopback-only CultNet/RUDP operation service at
|
|||
persists its payloads.
|
||||
|
||||
- service: `heimdall.private.commands`
|
||||
- operations: `heimdall.auth.begin`, `heimdall.auth.complete`
|
||||
- operations: `heimdall.auth.begin`, `heimdall.auth.complete`,
|
||||
`heimdall.auth.refresh`, `heimdall.auth.logout`
|
||||
- request envelope: `heimdall.private_command_envelope.v1`
|
||||
- begin result: `heimdall.auth_begin_receipt.v1`
|
||||
- completion result: `heimdall.auth_completion_receipt.v1`
|
||||
- refresh result: `heimdall.auth_refresh_receipt.v1`
|
||||
- logout result: `heimdall.auth_logout_receipt.v1`
|
||||
|
||||
Requests are accepted only from a configured app runtime and use the existing
|
||||
per-app shared secret. Expiry, nonce, operation, app, and idempotency identity
|
||||
|
|
@ -621,6 +623,12 @@ same result only for the exact same authenticated request; content changes are
|
|||
denied. Completion consumption remains single-use in Heimdall's canonical
|
||||
store.
|
||||
|
||||
Refresh claims are admitted only while their exact persisted session,
|
||||
app/account custody, access revision, and refresh expiry still match. Logout
|
||||
uses the refresh claim as custody proof and atomically advances the persisted
|
||||
access revision while expiring the session. A stale or concurrently superseded
|
||||
refresh cannot recreate the revoked session.
|
||||
|
||||
`gamecult.heimdall.access` exposes pure plugin ABI operations on the same
|
||||
loopback listener. Those operations describe, validate, or project access UI;
|
||||
they cannot create attempts, mint claims, or establish an app session.
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
fixtures, executable `describe`/`validate`/`project` ABI, and browser
|
||||
reference adapter
|
||||
- Heimdall persists OAuth attempts and serves authenticated
|
||||
`heimdall.auth.begin` / `heimdall.auth.complete` through loopback
|
||||
`heimdall.auth.begin` / `heimdall.auth.complete` / `heimdall.auth.refresh` /
|
||||
`heimdall.auth.logout` through loopback
|
||||
CultNet/RUDP on port `4101`
|
||||
- private commands are source-runtime, app-secret, expiry, nonce, and
|
||||
idempotency bound; their MessagePack payloads and completion claims are
|
||||
|
|
@ -39,6 +40,9 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
- the Ghostlight app profile now requires its caller-owned Discord role policy;
|
||||
Heimdall evaluates it and Ghostlight remains owner of local sessions,
|
||||
memberships, and campaigns
|
||||
- refresh verifies the exact persisted session revision before reissuing a
|
||||
claim; logout expires that session and advances its revision, so a stale or
|
||||
racing refresh cannot resurrect revoked custody
|
||||
|
||||
- this repo now has a first stateful Heimdall service slice
|
||||
- the canonical shared architecture lives in `docs/architecture.md`
|
||||
|
|
@ -165,10 +169,10 @@ unsigned `idunn.daemon_health`, merge its private key into Idunn, or make HTTP
|
|||
health an authority. The next product work is the Bifrost and remaining browser
|
||||
verification listed below.
|
||||
|
||||
For the active Ghostlight cut, first consume the access plugin and private
|
||||
command envelope in Ghostlight, then deploy Heimdall and Ghostlight through
|
||||
Idunn. Verify Odin sees the redacted plugin/route records while port `4101`
|
||||
remains loopback-only and no token appears in CultMesh, logs, or browser state.
|
||||
For the active Ghostlight cut, deploy the reviewed Heimdall and Ghostlight
|
||||
releases through Idunn. Verify Odin sees the redacted plugin/route records while
|
||||
port `4101` remains loopback-only and no token appears in CultMesh, logs, or
|
||||
browser state.
|
||||
|
||||
If the user asks to continue on Spotiverse, the current next move is the real
|
||||
browser `/auth/login` flow through public Heimdall. The Yggdrasil SSH tunnel
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@
|
|||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "heimdall.auth_logout_command.v1",
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": ["schema", "refreshToken"],
|
||||
"properties": {
|
||||
"schema": { "const": "heimdall.auth_logout_command.v1" },
|
||||
"refreshToken": { "type": "string", "minLength": 1 }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
|
|
|||
14
schemas/heimdall.auth_logout_receipt.v1.schema.json
Normal file
14
schemas/heimdall.auth_logout_receipt.v1.schema.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "heimdall.auth_logout_receipt.v1",
|
||||
"type": "object",
|
||||
"required": ["schema", "status", "sessionId", "accessRevision", "revokedAt"],
|
||||
"properties": {
|
||||
"schema": { "const": "heimdall.auth_logout_receipt.v1" },
|
||||
"status": { "const": "revoked" },
|
||||
"sessionId": { "type": "string", "minLength": 1 },
|
||||
"accessRevision": { "type": "integer", "minimum": 1 },
|
||||
"revokedAt": { "type": "string", "format": "date-time" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
10
src/app.ts
10
src/app.ts
|
|
@ -213,7 +213,7 @@ function parseRefreshTokenPayload(payload: Record<string, unknown>): RefreshToke
|
|||
return payload as unknown as RefreshTokenPayload;
|
||||
}
|
||||
|
||||
function verifyRefreshToken(
|
||||
export function verifyRefreshToken(
|
||||
token: string,
|
||||
appSlug: AppSlug,
|
||||
config: HeimdallConfig,
|
||||
|
|
@ -1432,6 +1432,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const persistedSession = await store.findSession(request.params.appSlug, refreshClaim.sid);
|
||||
if (!persistedSession
|
||||
|| persistedSession.accountId !== refreshClaim.account_id
|
||||
|| persistedSession.accessRevision !== refreshClaim.access_revision
|
||||
|| Date.parse(persistedSession.expiresAt) <= Date.parse(nowIso)) {
|
||||
reply.code(401);
|
||||
return { error: "revoked_or_stale_refresh_token" };
|
||||
}
|
||||
const account = await store.findAccountById(refreshClaim.account_id);
|
||||
const linkedIdentities = await store.listLinkedIdentitiesForAccount(refreshClaim.account_id);
|
||||
const grants = await store.listActiveGrants(refreshClaim.account_id, request.params.appSlug, nowIso);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
type CultNetOperationServer,
|
||||
} from "cultnet-ts";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { getHeimdallRuntimeContext } from "./app.js";
|
||||
import { getHeimdallRuntimeContext, verifyRefreshToken } from "./app.js";
|
||||
import { executeHeimdallAccessPlugin, HEIMDALL_ACCESS_PLUGIN_ID, type EvePluginAbiRequest } from "./access-plugin.js";
|
||||
import { appSlugs, oauthModes, providers, type AppSlug, type OAuthEntitlementPolicy, type OAuthMode, type Provider } from "./contracts.js";
|
||||
import { type HeimdallConfig } from "./config.js";
|
||||
|
|
@ -72,6 +72,8 @@ export async function startHeimdallPrivateCommandPlane(
|
|||
? "heimdall.auth_complete_command.v1"
|
||||
: request.operation === "heimdall.auth.refresh"
|
||||
? "heimdall.auth_refresh_command.v1"
|
||||
: request.operation === "heimdall.auth.logout"
|
||||
? "heimdall.auth_logout_command.v1"
|
||||
: undefined;
|
||||
if (!expectedContentSchema || envelope.contentSchema !== expectedContentSchema) {
|
||||
throw new Error("Private command content schema does not match its operation.");
|
||||
|
|
@ -161,9 +163,53 @@ async function executePrivateCommand(
|
|||
if (operation === "heimdall.auth.begin") return await beginAuth(app, config, appSlug, payload);
|
||||
if (operation === "heimdall.auth.complete") return await completeAuth(app, appSlug, payload);
|
||||
if (operation === "heimdall.auth.refresh") return await refreshAuth(app, config, appSlug, payload);
|
||||
if (operation === "heimdall.auth.logout") return await logoutAuth(app, config, appSlug, payload);
|
||||
throw new Error(`Unsupported Heimdall private operation '${operation}'.`);
|
||||
}
|
||||
|
||||
async function logoutAuth(
|
||||
app: FastifyInstance,
|
||||
config: HeimdallConfig,
|
||||
appSlug: AppSlug,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<{ status: string; payloadSchema: string; payload: Record<string, unknown> }> {
|
||||
const refreshToken = String(payload.refreshToken ?? "");
|
||||
if (!refreshToken) throw new Error("Auth logout requires the app's encrypted refresh claim.");
|
||||
const context = getHeimdallRuntimeContext(app);
|
||||
const claim = verifyRefreshToken(refreshToken, appSlug, config, context.keys);
|
||||
if (!claim) throw new Error("Auth logout received an invalid or expired refresh claim.");
|
||||
const revokedAt = new Date().toISOString();
|
||||
const session = await context.store.revokeSession(
|
||||
appSlug,
|
||||
claim.sid,
|
||||
claim.account_id,
|
||||
claim.access_revision,
|
||||
revokedAt,
|
||||
);
|
||||
if (!session || session.accessRevision !== claim.access_revision + 1) {
|
||||
throw new Error("Auth logout could not revoke the exact session custody claim.");
|
||||
}
|
||||
await context.store.createAuditEvent({
|
||||
accountId: claim.account_id,
|
||||
sessionId: claim.sid,
|
||||
appSlug,
|
||||
eventType: "session_revoked",
|
||||
eventPayloadJson: { previousAccessRevision: claim.access_revision, accessRevision: session.accessRevision },
|
||||
createdAt: revokedAt,
|
||||
});
|
||||
return {
|
||||
status: "accepted",
|
||||
payloadSchema: "heimdall.auth_logout_receipt.v1",
|
||||
payload: {
|
||||
schema: "heimdall.auth_logout_receipt.v1",
|
||||
status: "revoked",
|
||||
sessionId: claim.sid,
|
||||
accessRevision: session.accessRevision,
|
||||
revokedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshAuth(
|
||||
app: FastifyInstance,
|
||||
config: HeimdallConfig,
|
||||
|
|
|
|||
|
|
@ -229,11 +229,45 @@ export class InMemoryStore implements HeimdallStore {
|
|||
}
|
||||
|
||||
async createSession(input: CreateSessionInput): Promise<StoredSession> {
|
||||
const existing = this.sessions.get(input.id);
|
||||
if (existing && (existing.appSlug !== input.appSlug || existing.accountId !== input.accountId)) {
|
||||
throw new Error("Session custody cannot move between accounts or apps.");
|
||||
}
|
||||
if (existing && existing.accessRevision > input.accessRevision) {
|
||||
throw new Error("Session access revision cannot move backward.");
|
||||
}
|
||||
const session: StoredSession = clone(input);
|
||||
this.sessions.set(session.id, session);
|
||||
return clone(session);
|
||||
}
|
||||
|
||||
async findSession(appSlug: AppSlug, sessionId: string): Promise<StoredSession | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session?.appSlug === appSlug ? structuredClone(session) : null;
|
||||
}
|
||||
|
||||
async revokeSession(
|
||||
appSlug: AppSlug,
|
||||
sessionId: string,
|
||||
accountId: string,
|
||||
expectedAccessRevision: number,
|
||||
at: string,
|
||||
): Promise<StoredSession | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session
|
||||
|| session.appSlug !== appSlug
|
||||
|| session.accountId !== accountId
|
||||
|| session.accessRevision !== expectedAccessRevision) return null;
|
||||
const revoked = {
|
||||
...session,
|
||||
lastSeenAt: at,
|
||||
expiresAt: at,
|
||||
accessRevision: expectedAccessRevision + 1,
|
||||
};
|
||||
this.sessions.set(sessionId, revoked);
|
||||
return structuredClone(revoked);
|
||||
}
|
||||
|
||||
async createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt> {
|
||||
const attempt: StoredAuthAttempt = {
|
||||
handle: input.handle ?? randomUUID(),
|
||||
|
|
|
|||
|
|
@ -543,6 +543,9 @@ export class PostgresStore implements HeimdallStore {
|
|||
expires_at = EXCLUDED.expires_at,
|
||||
claims_json = EXCLUDED.claims_json,
|
||||
access_revision = EXCLUDED.access_revision
|
||||
WHERE sessions.account_id = EXCLUDED.account_id
|
||||
AND sessions.app_slug = EXCLUDED.app_slug
|
||||
AND sessions.access_revision <= EXCLUDED.access_revision
|
||||
RETURNING *
|
||||
`,
|
||||
[
|
||||
|
|
@ -560,6 +563,31 @@ export class PostgresStore implements HeimdallStore {
|
|||
return mapSessionRow(expectRow(result.rows[0], "createSession"));
|
||||
}
|
||||
|
||||
async findSession(appSlug: AppSlug, sessionId: string): Promise<StoredSession | null> {
|
||||
const result = await this.pool.query<SessionRow>(
|
||||
"SELECT * FROM sessions WHERE app_slug = $1 AND id = $2",
|
||||
[appSlug, sessionId],
|
||||
);
|
||||
return result.rowCount ? mapSessionRow(expectRow(result.rows[0], "findSession")) : null;
|
||||
}
|
||||
|
||||
async revokeSession(
|
||||
appSlug: AppSlug,
|
||||
sessionId: string,
|
||||
accountId: string,
|
||||
expectedAccessRevision: number,
|
||||
at: string,
|
||||
): Promise<StoredSession | null> {
|
||||
const result = await this.pool.query<SessionRow>(
|
||||
`UPDATE sessions
|
||||
SET last_seen_at = $5, expires_at = $5, access_revision = access_revision + 1
|
||||
WHERE app_slug = $1 AND id = $2 AND account_id = $3 AND access_revision = $4
|
||||
RETURNING *`,
|
||||
[appSlug, sessionId, accountId, expectedAccessRevision, at],
|
||||
);
|
||||
return result.rowCount ? mapSessionRow(expectRow(result.rows[0], "revokeSession")) : null;
|
||||
}
|
||||
|
||||
async createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt> {
|
||||
const handle = input.handle ?? randomUUID();
|
||||
const result = await this.pool.query<AuthAttemptRow>(
|
||||
|
|
|
|||
|
|
@ -206,6 +206,14 @@ export interface HeimdallStore {
|
|||
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
|
||||
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;
|
||||
createSession(input: CreateSessionInput): Promise<StoredSession>;
|
||||
findSession(appSlug: AppSlug, sessionId: string): Promise<StoredSession | null>;
|
||||
revokeSession(
|
||||
appSlug: AppSlug,
|
||||
sessionId: string,
|
||||
accountId: string,
|
||||
expectedAccessRevision: number,
|
||||
at: string,
|
||||
): Promise<StoredSession | null>;
|
||||
createAuthAttempt(input: CreateAuthAttemptInput): Promise<StoredAuthAttempt>;
|
||||
findAuthAttempt(appSlug: AppSlug, handle: string): Promise<StoredAuthAttempt | null>;
|
||||
updateAuthAttempt(
|
||||
|
|
|
|||
|
|
@ -153,6 +153,11 @@ function buildCommandBoundary(config: HeimdallConfig, pulse: HeimdallRuntimePuls
|
|||
requestSchema: "heimdall.private_command_envelope.v1",
|
||||
responseSchema: "heimdall.auth_refresh_receipt.v1",
|
||||
},
|
||||
{
|
||||
operation: "heimdall.auth.logout",
|
||||
requestSchema: "heimdall.private_command_envelope.v1",
|
||||
responseSchema: "heimdall.auth_logout_receipt.v1",
|
||||
},
|
||||
],
|
||||
privateRoute: {
|
||||
endpoint: `rudp://${config.privateCommandHost ?? "127.0.0.1"}:${config.privateCommandPort ?? 4101}`,
|
||||
|
|
|
|||
|
|
@ -31,3 +31,4 @@
|
|||
{"ts": "2026-06-16T16:59:54+00:00", "type": "implementation", "status": "accepted", "note": "Heimdall now writes a daemon-owned CultCache boundary store with provider advertisement, command boundary, transport profile, and daemon-health summary state, and can publish heimdall.cultnet-rudp-provider-health to Idunn over cultnet.transport.rudp.v0 when configured."}
|
||||
{"ts":"2026-08-20T18:32:47+00:00","type":"deployment","status":"ok","note":"Yggdrasil runs Heimdall commit 2a905354b9dc85101463030dea9045c1561a4638 with a persistent machine-bound Ed25519 provider-health identity and canonical idunn.signed_daemon_health.v1 publication. Idunn at Odin commit 5586910fa704b8a0d330b90a902a89b99566db77 has the exact root trust binding, retries interrupted UDP receives, respects whole-second ingress timestamp precision, admitted seven consecutive Heimdall pulses as authority=authenticated at the intended cadence, evaluated the daemon healthy, and maintained the redacted public provider-health .cc projection. Both services were active with zero restarts and the deployment brake engaged."}
|
||||
{"ts": "2026-08-21T22:37:58+00:00", "type": "implementation", "status": "accepted", "note": "Ghostlight auth now enters Heimdall through gamecult.heimdall.access plus a loopback CultNet operation plane. Heimdall persists attempts and completions; app-secret/source-runtime/expiry/nonce/idempotency binding and AES-256-GCM keep completion claims off public CultMesh and browser state. The plugin is projection-only and cannot grant access."}
|
||||
{"ts": "2026-08-21T23:53:15+00:00", "type": "implementation", "status": "ok", "note": "Ghostlight private auth now validates refresh against exact persisted session custody and revision; logout atomically expires that session and advances its revision, preventing stale or racing refresh from resurrecting revoked access."}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ invariants:
|
|||
- Heimdall owns its persistent provider-health signing identity and signed observations; Idunn owns root-bound admission and the public provider-health projection. Unsigned health is never lifecycle authority.
|
||||
- Heimdall owns OAuth attempts and completion redemption; the Eve access plugin and browser can project or carry one opaque handle but cannot grant access.
|
||||
- Private auth commands are app-secret, source-runtime, expiry, nonce, and idempotency bound; sensitive completion claims remain AES-GCM wrapped until the app backend receives them.
|
||||
- Refresh and logout are exact persisted-session transitions. App/account custody and access revision must match; logout advances the revision and stale refresh cannot recreate the session.
|
||||
- Persistent state cruft is the same failure class as architecture cruft.
|
||||
|
||||
data_flow:
|
||||
|
|
@ -69,6 +70,7 @@ active_subgoals:
|
|||
accepted_design:
|
||||
- `gamecult.heimdall.access` owns access-gate representation and browser navigation semantics; it has no access-grant or provider-state authority.
|
||||
- Heimdall's loopback CultNet operation service owns authenticated auth begin/complete commands, while retained HTTP integrations remain supported for existing consumers.
|
||||
- Ghostlight refresh and logout use the same private CultNet boundary; refresh re-evaluates the caller-owned KLTST policy and logout revokes exact Heimdall session custody.
|
||||
- `docs/architecture.md` is the canonical shared architecture note.
|
||||
- `docs/service-contract.md` records the concrete HTTP/JWKS contract of the landed service slice.
|
||||
- `docs/app-profiles/*.md` are thin app bindings, not duplicate brains.
|
||||
|
|
@ -92,6 +94,7 @@ accepted_design:
|
|||
|
||||
landed_baseline:
|
||||
- The Heimdall access plugin, persistent OAuth attempts, encrypted private CultNet commands, conformance fixtures, and browser reference adapter are implemented and locally verified.
|
||||
- Exact-session refresh validation and logout revocation are implemented; stale or racing refresh cannot resurrect a revoked session.
|
||||
- Shared architecture extracted into its own repo and renamed to Heimdall.
|
||||
- Repixelizer and StreamPixels app-profile notes created.
|
||||
- Repo-local state, handoff, and compaction scaffolding created.
|
||||
|
|
@ -120,5 +123,5 @@ rejected_paths:
|
|||
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
|
||||
|
||||
current_status:
|
||||
summary: The repository now contains the gamecult.heimdall.access Eve plugin, persistent auth attempts, and an authenticated encrypted loopback CultNet command plane for Ghostlight. The prior Yggdrasil release remains live until this commit is admitted through Idunn; signed health ownership remains unchanged.
|
||||
next_action: Commit and push this Heimdall cut, then update Idunn/Odin deployment truth and migrate Ghostlight to the plugin/private-command contract.
|
||||
summary: The repository contains the gamecult.heimdall.access Eve plugin, persistent auth attempts, and authenticated encrypted loopback CultNet begin, complete, refresh, and logout commands for Ghostlight. Refresh and logout are revision-bound to exact persisted session custody. The prior Yggdrasil release remains live until this commit is admitted through Idunn; signed health ownership remains unchanged.
|
||||
next_action: Deploy this Heimdall release with the Eve-native Ghostlight release through Idunn, then verify Odin discovery, loopback-only port 4101, real KLTST authentication, refresh, logout, and secret absence from public surfaces.
|
||||
|
|
|
|||
|
|
@ -158,6 +158,41 @@ describe("Heimdall Eve access plugin", () => {
|
|||
expect(refresh.accessToken).toEqual(expect.any(String));
|
||||
expect(refresh.refreshToken).toEqual(expect.any(String));
|
||||
|
||||
const logoutEnvelope = sealPrivateEnvelope({
|
||||
appSlug: "ghostlight",
|
||||
operation: "heimdall.auth.logout",
|
||||
contentSchema: "heimdall.auth_logout_command.v1",
|
||||
idempotencyKey: "logout-1",
|
||||
secret,
|
||||
payload: {
|
||||
schema: "heimdall.auth_logout_command.v1",
|
||||
refreshToken: refresh.refreshToken,
|
||||
},
|
||||
});
|
||||
const logoutResponse = await invoke(plane.endpoint, "heimdall.auth.logout", "transport-logout-1", logoutEnvelope);
|
||||
expect(logoutResponse.status).toBe("accepted");
|
||||
const logout = openPrivateEnvelope(decodeEnvelope(logoutResponse.payload), secret);
|
||||
expect(logout).toMatchObject({ schema: "heimdall.auth_logout_receipt.v1", status: "revoked" });
|
||||
|
||||
const staleRefreshEnvelope = sealPrivateEnvelope({
|
||||
appSlug: "ghostlight",
|
||||
operation: "heimdall.auth.refresh",
|
||||
contentSchema: "heimdall.auth_refresh_command.v1",
|
||||
idempotencyKey: "refresh-after-logout",
|
||||
secret,
|
||||
payload: {
|
||||
refreshToken: refresh.refreshToken,
|
||||
entitlementPolicy: {
|
||||
kind: "discord_role_access",
|
||||
guildId: "gamecult-guild",
|
||||
allowedRoleIds: ["role-ktlst"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const staleRefreshResponse = await invoke(plane.endpoint, "heimdall.auth.refresh", "transport-refresh-after-logout", staleRefreshEnvelope);
|
||||
const staleRefresh = openPrivateEnvelope(decodeEnvelope(staleRefreshResponse.payload), secret);
|
||||
expect(staleRefresh).toMatchObject({ schema: "heimdall.auth_refresh_receipt.v1", status: "denied" });
|
||||
|
||||
const retry = await invoke(plane.endpoint, "heimdall.auth.complete", "transport-complete-retry", completeEnvelope);
|
||||
expect(openPrivateEnvelope(decodeEnvelope(retry.payload), secret)).toEqual(completion);
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ describe("Heimdall verse runtime publication", () => {
|
|||
{ operation: "heimdall.auth.begin" },
|
||||
{ operation: "heimdall.auth.complete" },
|
||||
{ operation: "heimdall.auth.refresh" },
|
||||
{ operation: "heimdall.auth.logout" },
|
||||
],
|
||||
privateRoute: {
|
||||
endpoint: "rudp://127.0.0.1:4101",
|
||||
|
|
|
|||
Loading…
Reference in a new issue