Land one-time browser auth completion flow

This commit is contained in:
Meta Vens 2026-04-26 15:23:04 +01:00
commit 8f173e937a
18 changed files with 709 additions and 93 deletions

View file

@ -26,6 +26,7 @@ Landed right now:
- account, linked-identity, session, grant, entitlement snapshot, and audit
persistence
- a real Discord OAuth callback path for the first Repixelizer access flow
- a one-time browser completion exchange for opener `postMessage` handoff
- app profile surfaces for Repixelizer and StreamPixels
- signed claim issuance for app-local verification experiments

View file

@ -95,6 +95,22 @@ Heimdall should be involved in:
- session and claim issuance
- entitlement refresh
Preferred browser handoff:
1. Repixelizer opens Heimdall auth in a script-opened new tab/window
2. Heimdall completes provider auth and upstream entitlement checks
3. Heimdall callback posts a one-time completion code back to the opener
4. the callback context tries to close itself
5. Repixelizer backend redeems the completion code with Heimdall
6. Repixelizer establishes local trusted auth state from the redeemed result
Important rule:
- Repixelizer should not rely on a raw access token arriving in the callback
URL
- the browser may see a one-time completion code in fallback paths, but the
final trusted auth material should come from backend redemption
Repixelizer should still do locally:
- claim verification on guarded routes

View file

@ -349,6 +349,20 @@ The generic flow should be the same across hosted experiments:
The browser should never be the custodian of provider tokens.
Preferred browser handoff for hosted apps:
1. the app keeps its main page open
2. auth starts in a script-opened new browser context
3. Heimdall callback completes the provider flow server-side
4. Heimdall returns a short-lived one-time completion artifact
5. the callback page `postMessage`s that artifact back to the opener and tries
to close itself
6. the host app backend redeems the artifact with Heimdall and establishes the
app's local trusted auth state
This avoids spraying long-lived app auth into callback URLs while still giving
the user the familiar "log in over there, keep working over here" flow.
## App integration contract
Every hosted experiment should integrate through the same seams:

View file

@ -27,6 +27,8 @@ What exists right now is:
- persisted accounts, linked identities, sessions, grants, entitlement
snapshots, and audit records
- a real Discord provider callback slice for Repixelizer access
- a one-time browser completion handoff so auth callback pages can post back to
the opener instead of leaking final app auth in URL fragments
- explicit state/doctrine scaffolding so the plan does not immediately turn into
soup
@ -120,6 +122,7 @@ Next in this phase:
### Phase 3: Repixelizer binding
- integrate Heimdall into the hosted demo
- consume the one-time completion flow from the Repixelizer frontend/backend
- gate `POST /api/jobs`
- gate per-job read/cancel/event routes by local session ownership
- land a small verifier seam so Repixelizer can trust Heimdall JWTs locally

View file

@ -152,7 +152,8 @@ Failure mode right now:
Purpose:
- validate signed OAuth `state`
- serve as the landing surface for future code/token exchange
- complete the provider callback flow
- hand browser callers back to the app through a one-time completion exchange
Current status:
@ -161,13 +162,82 @@ Current status:
- for Discord, exchanges the authorization code, resolves the provider
identity, persists the local account/link/session/audit records, evaluates
Repixelizer entitlement facts, and issues a signed Heimdall access claim
- for browser-style callers, redirects back to `returnTo` with a fragment
carrying status and the issued access token
- for non-browser callers, returns JSON with the issued claim, session, account
summary, token metadata, and entitlement result
- for browser-style callers, renders a Heimdall-hosted completion page that
posts a one-time completion code back to the opener and tries to close
itself
- if opener handoff fails, the completion page still offers a fallback return
link carrying only the one-time completion code, not the access token
- for non-browser callers, returns JSON with the issued claim/session data plus
completion metadata
- other configured providers still return "not implemented" at the runtime
adapter layer until their callback paths are added
Success response for non-browser callers now includes:
```json
{
"completion": {
"code": "<one-time-code>",
"expiresAt": "2026-04-26T12:05:00.000Z",
"redeemEndpoint": "https://heimdall.gamecult.org/v1/apps/repixelizer/auth-completions/redeem"
}
}
```
### `POST /v1/apps/{appSlug}/auth-completions/redeem`
Purpose:
- redeem a short-lived one-time browser completion code
- return the trusted Heimdall auth result to the app backend
Request body:
```json
{
"completionCode": "<one-time-code>"
}
```
Response:
```json
{
"status": "success",
"provider": "discord",
"mode": "sign_in",
"appSlug": "repixelizer",
"account": {
"id": "acct_repixelizer_001",
"displayName": "Meta"
},
"session": {
"accountId": "acct_repixelizer_001",
"sessionId": "uuid",
"appSlug": "repixelizer",
"accessRevision": 1,
"expiresAt": "2026-04-26T13:00:00.000Z"
},
"accessToken": "<signed-jwt>",
"claimSet": {},
"verification": {
"issuer": "https://heimdall.gamecult.org",
"jwksUri": "https://heimdall.gamecult.org/.well-known/jwks.json",
"alg": "EdDSA",
"kid": "ed25519-..."
},
"sharedCapabilities": ["app_access", "queue_submit"],
"hybridCapabilities": []
}
```
Important behavior:
- completion codes are one-time use
- completion codes are short-lived
- app backends should redeem them server-side
- the browser should not keep the final access token in callback URL fragments
### `POST /v1/apps/{appSlug}/claims/issue`
Purpose:

View file

@ -43,10 +43,13 @@ Do not trust this file for the exact live HEAD. Always check git.
- `/.well-known/heimdall-configuration`
- `/v1/oauth/{provider}/start`
- `/v1/oauth/{provider}/callback`
- `/v1/apps/{appSlug}/auth-completions/redeem`
- `/v1/apps/{appSlug}/claims/issue`
- the Discord callback path now performs real code exchange, identity
resolution, auth persistence, entitlement evaluation, audit logging, and
claim issuance for the Repixelizer slice
- browser-style auth handoff now uses a one-time completion code posted back to
the opener, with backend redemption instead of fragment-token handoff
- Repixelizer is the first auth-blank binding target
- StreamPixels is the migration target with useful existing auth seams that
should not be flattened into mush
@ -73,8 +76,9 @@ the landed slice:
- persisted signing key material
- real token encryption at rest
- a reference verifier / middleware seam for app backends
- the first Repixelizer consumer integration that trusts Heimdall claims
locally
- the first Repixelizer consumer integration that opens auth in a new browser
context, redeems Heimdall completion codes server-side, and trusts Heimdall
claims locally
without dragging app-domain data into the shared layer and without forcing
per-request auth round-trips.

View file

@ -9,12 +9,14 @@ import {
type AppSlug,
type OAuthMode,
type OAuthConnectionBinding,
type RedeemAuthCompletionRequest,
type OAuthStatePayload,
type OAuthStartRequest,
type Provider,
} from "./contracts.js";
import { mapIssueClaimRequest, issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
import { getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
import { renderBrowserHandoffPage } from "./browser-handoff.js";
import { type HeimdallConfig, loadConfig } from "./config.js";
import { grantFact } from "./facts.js";
import { createOAuthRuntimeRegistry, type OAuthRuntimeRegistry } from "./oauth.js";
@ -120,20 +122,6 @@ function prefersHtml(acceptHeader: string | undefined): boolean {
return acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml");
}
function buildRedirectUrl(returnTo: string, params: Record<string, string | undefined>): string {
const url = new URL(returnTo);
const fragment = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value) {
fragment.set(key, value);
}
}
url.hash = fragment.toString();
return url.toString();
}
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
const config = options.config ?? loadConfig();
const store = options.store ?? (await createStore(config));
@ -326,14 +314,19 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
}
if (request.query.error) {
const redirectUrl = buildRedirectUrl(statePayload.return_to, {
heimdall_status: "error",
heimdall_provider: request.params.provider,
heimdall_error: "provider_error",
heimdall_error_description: request.query.error_description ?? request.query.error,
});
if (prefersHtml(request.headers.accept)) {
return reply.redirect(redirectUrl);
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
error: "provider_error",
errorDescription: request.query.error_description ?? request.query.error,
})
);
}
reply.code(400);
@ -342,22 +335,27 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
provider: request.params.provider,
providerError: request.query.error,
providerErrorDescription: request.query.error_description,
redirectUrl,
returnTo: statePayload.return_to,
};
}
if (!request.query.code) {
const redirectUrl = buildRedirectUrl(statePayload.return_to, {
heimdall_status: "error",
heimdall_provider: request.params.provider,
heimdall_error: "missing_code",
});
if (prefersHtml(request.headers.accept)) {
return reply.redirect(redirectUrl);
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
error: "missing_code",
})
);
}
reply.code(400);
return { error: "missing_code", redirectUrl };
return { error: "missing_code", returnTo: statePayload.return_to };
}
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${request.params.provider}/callback`;
@ -474,34 +472,79 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
createdAt: nowIso,
});
const redirectUrl = buildRedirectUrl(statePayload.return_to, {
heimdall_status: "success",
heimdall_provider: request.params.provider,
heimdall_app_slug: statePayload.app_slug,
heimdall_account_id: account.id,
heimdall_session_id: issued.session.sessionId,
heimdall_access_token: issued.accessToken,
const accountSummary: {
id: string;
displayName?: string;
primaryEmail?: string;
} = {
id: account.id,
};
if (account.displayName !== undefined) {
accountSummary.displayName = account.displayName;
}
if (account.primaryEmail !== undefined) {
accountSummary.primaryEmail = account.primaryEmail;
}
const completionPayload = {
status: "success",
provider: request.params.provider,
mode: statePayload.mode,
appSlug: statePayload.app_slug,
account: accountSummary,
entitlements: entitlementEvaluation,
returnTo: statePayload.return_to,
...issued,
};
const completionExpiresAt = new Date(Date.now() + config.completionTtlSeconds * 1000).toISOString();
const completion = await store.createAuthCompletion({
appSlug: statePayload.app_slug,
provider: request.params.provider,
mode: statePayload.mode,
accountId: account.id,
sessionId: issued.session.sessionId,
returnTo: statePayload.return_to,
createdAt: nowIso,
expiresAt: completionExpiresAt,
payloadJson: completionPayload as unknown as Record<string, unknown>,
});
await store.createAuditEvent({
accountId: account.id,
sessionId: issued.session.sessionId,
appSlug: statePayload.app_slug,
eventType: "auth_completion_created",
eventPayloadJson: {
provider: request.params.provider,
mode: statePayload.mode,
completionCode: completion.code,
expiresAt: completion.expiresAt,
},
createdAt: nowIso,
});
if (prefersHtml(request.headers.accept)) {
return reply.redirect(redirectUrl);
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
status: "success",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
completionCode: completion.code,
})
);
}
reply.code(201);
return {
provider: request.params.provider,
mode: statePayload.mode,
account,
linkedIdentity: identity,
token: {
tokenType: tokenSet.tokenType,
scope: tokenSet.scope,
expiresAt: tokenSet.expiresAt,
completion: {
code: completion.code,
expiresAt: completion.expiresAt,
redeemEndpoint: `${config.publicBaseUrl}/v1/apps/${statePayload.app_slug}/auth-completions/redeem`,
},
entitlements: entitlementEvaluation,
redirectUrl,
returnTo: statePayload.return_to,
...issued,
...completionPayload,
};
} catch (error) {
const message = error instanceof Error ? error.message : "OAuth callback failed.";
@ -517,14 +560,19 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
createdAt: nowIso,
});
const redirectUrl = buildRedirectUrl(statePayload.return_to, {
heimdall_status: "error",
heimdall_provider: request.params.provider,
heimdall_error: "oauth_callback_failed",
heimdall_error_description: message,
});
if (prefersHtml(request.headers.accept)) {
return reply.redirect(redirectUrl);
reply.type("text/html; charset=utf-8");
return reply.send(
renderBrowserHandoffPage({
status: "error",
provider: request.params.provider,
appSlug: statePayload.app_slug,
mode: statePayload.mode,
returnTo: statePayload.return_to,
error: "oauth_callback_failed",
errorDescription: message,
})
);
}
reply.code(502);
@ -533,12 +581,61 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
detail: message,
provider: request.params.provider,
appSlug: statePayload.app_slug,
redirectUrl,
returnTo: statePayload.return_to,
};
}
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: RedeemAuthCompletionRequest }>(
"/v1/apps/:appSlug/auth-completions/redeem",
{
schema: {
params: {
type: "object",
required: ["appSlug"],
additionalProperties: false,
properties: {
appSlug: { type: "string", enum: [...appSlugs] },
},
},
body: {
type: "object",
required: ["completionCode"],
additionalProperties: false,
properties: {
completionCode: { type: "string", minLength: 1 },
},
},
},
},
async (request, reply) => {
const nowIso = new Date().toISOString();
const completion = await store.consumeAuthCompletion(request.params.appSlug, request.body.completionCode, nowIso);
if (!completion) {
reply.code(410);
return { error: "invalid_or_expired_completion_code" };
}
await store.createAuditEvent({
accountId: completion.accountId,
sessionId: completion.sessionId,
appSlug: completion.appSlug,
eventType: "auth_completion_redeemed",
eventPayloadJson: {
provider: completion.provider,
mode: completion.mode,
completionCode: completion.code,
},
createdAt: nowIso,
});
reply.code(201);
return completion.payloadJson;
}
);
app.post<{ Params: { appSlug: AppSlug }; Body: import("./contracts.js").IssueClaimRequest }>(
"/v1/apps/:appSlug/claims/issue",
{

160
src/browser-handoff.ts Normal file
View file

@ -0,0 +1,160 @@
import { type AppSlug, type OAuthMode, type Provider } from "./contracts.js";
interface BrowserHandoffBase {
provider: Provider;
returnTo: string;
}
export interface BrowserHandoffSuccess extends BrowserHandoffBase {
status: "success";
appSlug: AppSlug;
mode: OAuthMode;
completionCode: string;
}
export interface BrowserHandoffError extends BrowserHandoffBase {
status: "error";
appSlug: AppSlug;
mode?: OAuthMode;
error: string;
errorDescription?: string;
}
export type BrowserHandoffPayload = BrowserHandoffSuccess | BrowserHandoffError;
function serializeForScript(value: unknown): string {
return JSON.stringify(value).replace(/</g, "\\u003c");
}
function buildBrowserFallbackUrl(payload: BrowserHandoffPayload): string {
const url = new URL(payload.returnTo);
const fragment = new URLSearchParams({
heimdall_status: payload.status,
heimdall_provider: payload.provider,
});
if ("appSlug" in payload) {
fragment.set("heimdall_app_slug", payload.appSlug);
}
if ("mode" in payload && payload.mode) {
fragment.set("heimdall_mode", payload.mode);
}
if (payload.status === "success") {
fragment.set("heimdall_completion_code", payload.completionCode);
} else {
fragment.set("heimdall_error", payload.error);
if (payload.errorDescription) {
fragment.set("heimdall_error_description", payload.errorDescription);
}
}
url.hash = fragment.toString();
return url.toString();
}
export function renderBrowserHandoffPage(payload: BrowserHandoffPayload): string {
const targetOrigin = new URL(payload.returnTo).origin;
const fallbackUrl = buildBrowserFallbackUrl(payload);
const scriptPayload = serializeForScript({
source: "heimdall",
kind: "oauth_result",
...payload,
});
const scriptOrigin = serializeForScript(targetOrigin);
const scriptFallbackUrl = serializeForScript(fallbackUrl);
const scriptTitle = serializeForScript(
payload.status === "success" ? "Authentication Complete" : "Authentication Failed"
);
const scriptBody = serializeForScript(
payload.status === "success"
? "You can return to the app now."
: "The authentication flow failed. Return to the app to continue."
);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${payload.status === "success" ? "Authentication Complete" : "Authentication Failed"}</title>
<style>
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, sans-serif;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #11161d;
color: #f4f7fb;
}
main {
width: min(32rem, calc(100vw - 2rem));
border-radius: 1rem;
padding: 1.5rem;
background: rgba(20, 27, 36, 0.96);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
}
h1 {
margin: 0 0 0.75rem;
font-size: 1.35rem;
}
p {
margin: 0 0 1rem;
line-height: 1.5;
color: #d5dce6;
}
a {
display: inline-block;
margin-top: 0.5rem;
color: #11161d;
background: #d8eefe;
text-decoration: none;
border-radius: 999px;
padding: 0.75rem 1rem;
font-weight: 600;
}
small {
display: block;
margin-top: 0.9rem;
color: #9aacbe;
}
</style>
</head>
<body>
<main>
<h1 id="title"></h1>
<p id="body"></p>
<a id="return-link" href="${fallbackUrl}">Return to the app</a>
<small>If this tab does not close on its own, you can close it manually.</small>
</main>
<script>
const payload = ${scriptPayload};
const targetOrigin = ${scriptOrigin};
const fallbackUrl = ${scriptFallbackUrl};
const title = ${scriptTitle};
const body = ${scriptBody};
document.getElementById("title").textContent = title;
document.getElementById("body").textContent = body;
document.getElementById("return-link").setAttribute("href", fallbackUrl);
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(payload, targetOrigin);
}
} catch {}
setTimeout(() => {
try {
window.close();
} catch {}
}, 50);
</script>
</body>
</html>`;
}

View file

@ -24,6 +24,7 @@ export interface HeimdallConfig {
issuer: string;
sessionTtlSeconds: number;
stateTtlSeconds: number;
completionTtlSeconds: number;
signingPrivateKeyPem?: string;
signingKeyId?: string;
storage: StorageConfig;
@ -110,6 +111,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig
issuer,
sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600),
stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600),
completionTtlSeconds: readInt(env.GC_ACCESS_COMPLETION_TTL_SECONDS, 300),
storage: {
backend: storageBackend,
applySchemaOnStartup: readBoolean(env.GC_ACCESS_APPLY_SCHEMA_ON_STARTUP, true),

View file

@ -61,6 +61,10 @@ export interface IssueClaimRequest {
ttlSeconds?: number;
}
export interface RedeemAuthCompletionRequest {
completionCode: string;
}
export interface AccessClaimPayload {
iss: string;
aud: AppSlug;

View file

@ -2,12 +2,14 @@ import { randomUUID } from "node:crypto";
import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js";
import {
type CreateAccountInput,
type CreateAuthCompletionInput,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CreateEntitlementSnapshotInput,
type CreateSessionInput,
type HeimdallStore,
type StoredAccount,
type StoredAuthCompletion,
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type StoredSession,
@ -35,6 +37,7 @@ export class InMemoryStore implements HeimdallStore {
private readonly linkedIdentities = new Map<string, StoredLinkedIdentity>();
private readonly grants = new Map<string, StoredCapabilityGrant>();
private readonly sessions = new Map<string, StoredSession>();
private readonly authCompletions = new Map<string, StoredAuthCompletion>();
private readonly entitlementSnapshots = new Map<string, CreateEntitlementSnapshotInput>();
private readonly auditEvents = new Map<string, CreateAuditEventInput>();
@ -195,6 +198,38 @@ export class InMemoryStore implements HeimdallStore {
return clone(session);
}
async createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion> {
const completion: StoredAuthCompletion = {
code: input.code ?? randomUUID(),
appSlug: input.appSlug,
provider: input.provider,
mode: input.mode,
accountId: input.accountId,
sessionId: input.sessionId,
returnTo: input.returnTo,
createdAt: input.createdAt,
expiresAt: input.expiresAt,
payloadJson: clone(input.payloadJson),
};
this.authCompletions.set(completion.code, clone(completion));
return clone(completion);
}
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
const completion = this.authCompletions.get(code);
if (!completion) {
return null;
}
if (completion.appSlug !== appSlug || completion.expiresAt <= at || completion.consumedAt) {
return null;
}
completion.consumedAt = at;
return clone(completion);
}
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
const key = `${input.accountId}:${input.provider}:${input.scope}`;
this.entitlementSnapshots.set(key, clone(input));

View file

@ -4,12 +4,14 @@ import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contra
import { CREATE_SCHEMA_SQL } from "./schema.js";
import {
type CreateAccountInput,
type CreateAuthCompletionInput,
type CreateAuditEventInput,
type CreateCapabilityGrantInput,
type CreateEntitlementSnapshotInput,
type CreateSessionInput,
type HeimdallStore,
type StoredAccount,
type StoredAuthCompletion,
type StoredCapabilityGrant,
type StoredLinkedIdentity,
type StoredSession,
@ -66,6 +68,20 @@ interface SessionRow extends QueryResultRow {
claims_json: Record<string, unknown>;
}
interface AuthCompletionRow extends QueryResultRow {
code: string;
app_slug: AppSlug;
provider: Provider;
mode: "sign_in" | "link" | "connect";
account_id: string;
session_id: string;
return_to: string;
payload_json: Record<string, unknown>;
created_at: string | Date;
expires_at: string | Date;
consumed_at: string | Date | null;
}
function expectRow<T>(row: T | undefined, label: string): T {
if (!row) {
throw new Error(`${label} query returned no rows.`);
@ -78,6 +94,10 @@ function nullable<T>(value: T | undefined): T | null {
return value ?? null;
}
function normalizeTimestamp(value: string | Date): string {
return value instanceof Date ? value.toISOString() : value;
}
function parseScopes(value: string): string[] {
return value.split(" ").map((item) => item.trim()).filter(Boolean);
}
@ -179,6 +199,27 @@ function mapSessionRow(row: SessionRow): StoredSession {
};
}
function mapAuthCompletionRow(row: AuthCompletionRow): StoredAuthCompletion {
const completion: StoredAuthCompletion = {
code: row.code,
appSlug: row.app_slug,
provider: row.provider,
mode: row.mode,
accountId: row.account_id,
sessionId: row.session_id,
returnTo: row.return_to,
createdAt: normalizeTimestamp(row.created_at),
expiresAt: normalizeTimestamp(row.expires_at),
payloadJson: row.payload_json,
};
if (row.consumed_at) {
completion.consumedAt = normalizeTimestamp(row.consumed_at);
}
return completion;
}
export class PostgresStore implements HeimdallStore {
constructor(private readonly pool: Pick<Pool, "query" | "end">) {}
@ -395,6 +436,51 @@ export class PostgresStore implements HeimdallStore {
return mapSessionRow(expectRow(result.rows[0], "createSession"));
}
async createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion> {
const code = input.code ?? randomUUID();
const result = await this.pool.query<AuthCompletionRow>(
`
INSERT INTO auth_completions (
code, app_slug, provider, mode, account_id, session_id,
return_to, payload_json, created_at, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`,
[
code,
input.appSlug,
input.provider,
input.mode,
input.accountId,
input.sessionId,
input.returnTo,
JSON.stringify(input.payloadJson),
input.createdAt,
input.expiresAt,
]
);
return mapAuthCompletionRow(expectRow(result.rows[0], "createAuthCompletion"));
}
async consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null> {
const result = await this.pool.query<AuthCompletionRow>(
`
UPDATE auth_completions
SET consumed_at = $3
WHERE code = $1
AND app_slug = $2
AND consumed_at IS NULL
AND expires_at > $3
RETURNING *
`,
[code, appSlug, at]
);
return result.rowCount ? mapAuthCompletionRow(expectRow(result.rows[0], "consumeAuthCompletion")) : null;
}
async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void> {
await this.pool.query(
`

View file

@ -42,6 +42,23 @@ CREATE TABLE IF NOT EXISTS sessions (
CREATE INDEX IF NOT EXISTS sessions_account_id_idx
ON sessions(account_id);
CREATE TABLE IF NOT EXISTS auth_completions (
code TEXT PRIMARY KEY,
app_slug TEXT NOT NULL,
provider TEXT NOT NULL,
mode TEXT NOT NULL,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
return_to TEXT NOT NULL,
payload_json JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS auth_completions_lookup_idx
ON auth_completions(app_slug, expires_at, consumed_at);
CREATE TABLE IF NOT EXISTS capability_grants (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,

View file

@ -1,4 +1,4 @@
import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js";
import { type AppSlug, type LinkedIdentityInput, type OAuthMode, type Provider } from "../contracts.js";
export interface StoredAccount {
id: string;
@ -121,6 +121,33 @@ export interface CreateAuditEventInput {
createdAt: string;
}
export interface StoredAuthCompletion {
code: string;
appSlug: AppSlug;
provider: Provider;
mode: OAuthMode;
accountId: string;
sessionId: string;
returnTo: string;
createdAt: string;
expiresAt: string;
consumedAt?: string;
payloadJson: Record<string, unknown>;
}
export interface CreateAuthCompletionInput {
code?: string;
appSlug: AppSlug;
provider: Provider;
mode: OAuthMode;
accountId: string;
sessionId: string;
returnTo: string;
createdAt: string;
expiresAt: string;
payloadJson: Record<string, unknown>;
}
export interface HeimdallStore {
ensureSchema(): Promise<void>;
close(): Promise<void>;
@ -132,6 +159,8 @@ export interface HeimdallStore {
createCapabilityGrant(input: CreateCapabilityGrantInput): Promise<StoredCapabilityGrant>;
listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise<StoredCapabilityGrant[]>;
createSession(input: CreateSessionInput): Promise<StoredSession>;
createAuthCompletion(input: CreateAuthCompletionInput): Promise<StoredAuthCompletion>;
consumeAuthCompletion(appSlug: AppSlug, code: string, at: string): Promise<StoredAuthCompletion | null>;
upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise<void>;
createAuditEvent(input: CreateAuditEventInput): Promise<void>;
}

View file

@ -6,3 +6,4 @@
{"ts": "2026-04-26T11:24:39+00:00", "type": "design", "status": "accepted", "note": "The first Heimdall implementation pass landed as a standalone TypeScript/Fastify service skeleton with Ed25519 JWT signing, JWKS discovery, signed OAuth state, and app-profile-driven claim issuance for Repixelizer and StreamPixels."}
{"ts": "2026-04-26T11:49:41+00:00", "type": "implementation", "status": "accepted", "note": "The first stateful Heimdall slice landed durable auth-control-plane persistence plus a real Discord callback path for Repixelizer, with accounts, linked identities, sessions, grants, entitlement snapshots, and audit events stored behind in-memory/Postgres backends."}
{"ts": "2026-04-26T13:58:45+00:00", "type": "design", "status": "accepted", "note": "Heimdall claim facts should prefer provider-agnostic, app-facing entitlement names such as entitlement.app_access. Provider-specific evidence like Discord role matches stays in linked identities and entitlement snapshots unless a consumer truly needs that upstream detail in the claim surface."}
{"ts": "2026-04-26T14:22:37+00:00", "type": "implementation", "status": "accepted", "note": "Browser-style Heimdall auth handoff now uses a short-lived one-time completion code: the callback page posts the result to the opener, tries to close itself, and the app backend redeems the code with Heimdall instead of receiving the final access token in a callback URL fragment."}

View file

@ -88,5 +88,5 @@ rejected_paths:
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
current_status:
summary: Heimdall now has a stateful first provider slice: TypeScript/Fastify runtime, Ed25519 JWT signing, JWKS/discovery exposure, signed OAuth start-state handling, durable auth/control-plane persistence, and a real Discord callback path that resolves identity, evaluates Repixelizer entitlements, records audit state, and issues signed claims. The key boundary still holds: Heimdall owns auth/control-plane authority and credential custody; app-domain bindings and runtime behavior remain app-owned.
next_action: Land the first consumer seam and harden custody: persisted signing key material, real token encryption at rest, and a small verifier/integration contract so Repixelizer can trust Heimdall claims locally.
summary: Heimdall now has a stateful first provider slice plus a proper browser handoff: TypeScript/Fastify runtime, Ed25519 JWT signing, JWKS/discovery exposure, signed OAuth start-state handling, durable auth/control-plane persistence, a real Discord callback path, and a one-time completion-code exchange that lets browser auth contexts post back to the opener without leaking final app auth in URL fragments. The key boundary still holds: Heimdall owns auth/control-plane authority and credential custody; app-domain bindings and runtime behavior remain app-owned.
next_action: Consume the new handoff in the first real Repixelizer integration and harden custody: persisted signing key material, real token encryption at rest, and a small verifier/integration contract so Repixelizer can redeem Heimdall completion codes server-side and trust Heimdall claims locally.

View file

@ -15,6 +15,7 @@ function createTestConfig(): HeimdallConfig {
issuer: "https://heimdall.gamecult.org",
sessionTtlSeconds: 3600,
stateTtlSeconds: 600,
completionTtlSeconds: 300,
storage: {
backend: "memory",
applySchemaOnStartup: true,
@ -198,6 +199,12 @@ describe("Heimdall service", () => {
expect(response.statusCode).toBe(201);
const payload = response.json();
expect(payload.completion).toEqual(
expect.objectContaining({
code: expect.any(String),
redeemEndpoint: "https://heimdall.gamecult.org/v1/apps/repixelizer/auth-completions/redeem",
})
);
expect(payload.sharedCapabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
expect(payload.account).toEqual(
expect.objectContaining({
@ -224,6 +231,75 @@ describe("Heimdall service", () => {
}
});
it("redeems a completion code exactly once", async () => {
const app = await buildApp({
config: createTestConfig(),
oauthRuntimes: {
discord: createMockDiscordRuntime(),
},
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const callbackResponse = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
});
const completionCode = callbackResponse.json().completion.code as string;
const redeemResponse = await app.inject({
method: "POST",
url: "/v1/apps/repixelizer/auth-completions/redeem",
payload: {
completionCode,
},
});
expect(redeemResponse.statusCode).toBe(201);
expect(redeemResponse.json()).toEqual(
expect.objectContaining({
status: "success",
provider: "discord",
appSlug: "repixelizer",
})
);
const secondRedeemResponse = await app.inject({
method: "POST",
url: "/v1/apps/repixelizer/auth-completions/redeem",
payload: {
completionCode,
},
});
expect(secondRedeemResponse.statusCode).toBe(410);
});
it("renders a browser handoff page that posts a completion code instead of a token", async () => {
const app = await buildApp({
config: createTestConfig(),
oauthRuntimes: {
discord: createMockDiscordRuntime(),
},
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
headers: {
accept: "text/html",
},
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain("text/html");
expect(response.body).toContain("window.opener.postMessage");
expect(response.body).toContain("heimdall_completion_code");
expect(response.body).not.toContain("heimdall_access_token");
});
it("issues direct claims from provider-agnostic entitlement facts", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);
@ -252,33 +328,6 @@ describe("Heimdall service", () => {
);
});
it("redirects browser callbacks back to the app with a token fragment", async () => {
const app = await buildApp({
config: createTestConfig(),
oauthRuntimes: {
discord: createMockDiscordRuntime(),
},
});
apps.push(app);
const stateToken = await startDiscordSignIn(app);
const response = await app.inject({
method: "GET",
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
headers: {
accept: "text/html",
},
});
expect(response.statusCode).toBe(302);
const redirectUrl = new URL(response.headers.location ?? "");
expect(redirectUrl.origin + redirectUrl.pathname).toBe("https://repixelizer.gamecult.org/app/");
const fragment = new URLSearchParams(redirectUrl.hash.slice(1));
expect(fragment.get("heimdall_status")).toBe("success");
expect(fragment.get("heimdall_provider")).toBe("discord");
expect(fragment.get("heimdall_access_token")).toBeTruthy();
});
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {
const app = await buildApp({ config: createTestConfig() });
apps.push(app);

View file

@ -108,6 +108,34 @@ describe("PostgresStore", () => {
createdAt: now,
});
const completion = await store.createAuthCompletion({
appSlug: "repixelizer",
provider: "discord",
mode: "sign_in",
accountId: account.id,
sessionId: "session-123",
returnTo: "https://repixelizer.gamecult.org/app/",
createdAt: now,
expiresAt: "2026-04-26T12:05:00.000Z",
payloadJson: {
status: "success",
provider: "discord",
appSlug: "repixelizer",
},
});
const consumed = await store.consumeAuthCompletion("repixelizer", completion.code, "2026-04-26T12:01:00.000Z");
expect(consumed).toEqual(
expect.objectContaining({
code: completion.code,
appSlug: "repixelizer",
consumedAt: "2026-04-26T12:01:00.000Z",
})
);
const consumedAgain = await store.consumeAuthCompletion("repixelizer", completion.code, "2026-04-26T12:02:00.000Z");
expect(consumedAgain).toBeNull();
await store.close();
});
});