Land one-time browser auth completion flow
This commit is contained in:
parent
12ce1f45ae
commit
8f173e937a
18 changed files with 709 additions and 93 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
211
src/app.ts
211
src/app.ts
|
|
@ -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
160
src/browser-handoff.ts
Normal 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>`;
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -61,6 +61,10 @@ export interface IssueClaimRequest {
|
|||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface RedeemAuthCompletionRequest {
|
||||
completionCode: string;
|
||||
}
|
||||
|
||||
export interface AccessClaimPayload {
|
||||
iss: string;
|
||||
aud: AppSlug;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue