Land Heimdall service skeleton
This commit is contained in:
parent
49fb467950
commit
58a12a2835
20 changed files with 3001 additions and 33 deletions
19
.env.example
Normal file
19
.env.example
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
HOST=127.0.0.1
|
||||
PORT=4100
|
||||
GC_ACCESS_BASE_URL=http://127.0.0.1:4100
|
||||
GC_ACCESS_ISSUER=http://127.0.0.1:4100
|
||||
GC_ACCESS_SESSION_TTL_SECONDS=3600
|
||||
GC_ACCESS_STATE_TTL_SECONDS=600
|
||||
GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=
|
||||
GC_ACCESS_SIGNING_KEY_ID=
|
||||
|
||||
GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=
|
||||
GC_ACCESS_PROVIDER_PATREON_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET=
|
||||
GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET=
|
||||
GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET=
|
||||
GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID=
|
||||
GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET=
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,3 +1,7 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.pnpm-store/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -13,18 +13,42 @@ The point is painfully simple:
|
|||
|
||||
## Current Shape
|
||||
|
||||
This repo is still planning and service-contract work, not a landed auth
|
||||
service yet.
|
||||
This repo now has an initial standalone service skeleton.
|
||||
|
||||
Landed right now:
|
||||
|
||||
- a TypeScript/Fastify service scaffold under `src/`
|
||||
- Ed25519 JWT signing plus `/.well-known/jwks.json`
|
||||
- signed OAuth start-state scaffolding for Discord, Patreon, GitHub, Twitch,
|
||||
and YouTube
|
||||
- app profile surfaces for Repixelizer and StreamPixels
|
||||
- signed claim issuance for app-local verification experiments
|
||||
|
||||
Still not landed:
|
||||
|
||||
- provider token exchange on callback
|
||||
- durable account/session/grant storage
|
||||
- end-to-end app integrations
|
||||
|
||||
Canonical docs:
|
||||
|
||||
- `docs/architecture.md`
|
||||
- `docs/implementation-plan.md`
|
||||
- `docs/service-contract.md`
|
||||
- `docs/app-profiles/repixelizer.md`
|
||||
- `docs/app-profiles/streampixels.md`
|
||||
- `state/map.yaml`
|
||||
- `notes/fresh-workspace-handoff.md`
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
pnpm dev
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Working Loop
|
||||
|
||||
1. Rehydrate from canonical state.
|
||||
|
|
|
|||
|
|
@ -420,6 +420,10 @@ Suggested generic env surface:
|
|||
- `GC_ACCESS_BASE_URL=https://heimdall.gamecult.org`
|
||||
- `GC_ACCESS_INTERNAL_URL=http://127.0.0.1:4100`
|
||||
- `GC_ACCESS_SESSION_SECRET=...`
|
||||
- `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM=...`
|
||||
- `GC_ACCESS_SIGNING_KEY_ID=...`
|
||||
- `GC_ACCESS_SESSION_TTL_SECONDS=3600`
|
||||
- `GC_ACCESS_STATE_TTL_SECONDS=600`
|
||||
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID=...`
|
||||
- `GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET=...`
|
||||
- `GC_ACCESS_PROVIDER_DISCORD_BOT_TOKEN=...`
|
||||
|
|
@ -438,6 +442,10 @@ Suggested generic env surface:
|
|||
|
||||
Only configure the providers Heimdall actually owns for a given deployment.
|
||||
|
||||
If `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` is absent, the current skeleton falls
|
||||
back to an ephemeral dev signing key. That is acceptable for local iteration
|
||||
and absolutely not acceptable for real deployment.
|
||||
|
||||
Per-app binding should live in code or profile config, not in cloned provider
|
||||
env namespaces. App-local binding and runtime config such as creator
|
||||
associations, webhook subscriptions, sync toggles, or workspace/project
|
||||
|
|
|
|||
|
|
@ -12,14 +12,19 @@ and the architecture note first, then fix this file.
|
|||
|
||||
## Current machine
|
||||
|
||||
There is no landed auth machine here yet.
|
||||
There is now a first landed Heimdall skeleton.
|
||||
|
||||
What exists right now is:
|
||||
|
||||
- the shared architecture note
|
||||
- the concrete service contract in `docs/service-contract.md`
|
||||
- app-binding notes for Repixelizer and StreamPixels
|
||||
- explicit state/doctrine scaffolding so the plan does not immediately turn
|
||||
into soup
|
||||
- a standalone TypeScript/Fastify service skeleton under `src/`
|
||||
- Ed25519 JWT signing plus JWKS/discovery exposure
|
||||
- OAuth start/callback contract surfaces with signed `state`
|
||||
- app-profile-driven claim issuance for Repixelizer and StreamPixels
|
||||
- explicit state/doctrine scaffolding so the plan does not immediately turn into
|
||||
soup
|
||||
|
||||
## Primary objective
|
||||
|
||||
|
|
@ -80,21 +85,33 @@ Goals:
|
|||
|
||||
### Phase 1: Contract and schema scaffold
|
||||
|
||||
- define:
|
||||
- signed claim/session payloads
|
||||
- JWKS or equivalent verification surface
|
||||
- OAuth `state` and callback contracts
|
||||
- grant / entitlement / audit record shapes
|
||||
- app profile / capability rule inputs and outputs
|
||||
Landed in the first pass:
|
||||
|
||||
- signed claim/session payload shape
|
||||
- Ed25519 JWKS verification surface
|
||||
- OAuth `state` and callback contract surfaces
|
||||
- app profile / capability rule inputs and outputs
|
||||
|
||||
Still to do in this phase:
|
||||
|
||||
- durable grant / entitlement / audit record shapes
|
||||
- persistence-backed account and linked-identity schema details
|
||||
|
||||
### Phase 2: Heimdall service skeleton
|
||||
|
||||
- create the first real service layout for:
|
||||
- provider OAuth start/callback routes
|
||||
- account / linked-identity persistence
|
||||
- session issuance and refresh
|
||||
- capability evaluation
|
||||
- audit logging
|
||||
Landed in the first pass:
|
||||
|
||||
- provider OAuth start route scaffolding
|
||||
- callback route scaffolding with signed-state validation
|
||||
- session/claim issuance contract
|
||||
- capability evaluation for shared app capabilities
|
||||
|
||||
Next in this phase:
|
||||
|
||||
- account / linked-identity persistence
|
||||
- provider token exchange and refresh
|
||||
- audit logging
|
||||
- non-ephemeral signing key handling
|
||||
|
||||
### Phase 3: Repixelizer binding
|
||||
|
||||
|
|
|
|||
272
docs/service-contract.md
Normal file
272
docs/service-contract.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Heimdall Service Contract
|
||||
|
||||
This file describes the first landed Heimdall service skeleton.
|
||||
|
||||
It is the concrete HTTP/JWKS contract that now exists in the repo. It is not a
|
||||
claim that provider token exchange, persistence, or app bindings are fully
|
||||
implemented yet.
|
||||
|
||||
## Current stack
|
||||
|
||||
- runtime: Node.js `24+`
|
||||
- service framework: Fastify
|
||||
- language: TypeScript
|
||||
- signing: Ed25519 JWTs with JWKS exposure
|
||||
|
||||
## Current routes
|
||||
|
||||
### `GET /healthz`
|
||||
|
||||
Purpose:
|
||||
|
||||
- basic liveness probe
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"service": "heimdall",
|
||||
"issuer": "https://heimdall.gamecult.org",
|
||||
"now": "2026-04-26T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /.well-known/jwks.json`
|
||||
|
||||
Purpose:
|
||||
|
||||
- publish the public signing key that app backends use to verify Heimdall JWTs
|
||||
|
||||
Notes:
|
||||
|
||||
- current skeleton signs with Ed25519
|
||||
- if `GC_ACCESS_SIGNING_PRIVATE_KEY_PEM` is absent, the service generates an
|
||||
ephemeral dev key at startup
|
||||
- production should use a persisted private key and stable `kid`
|
||||
|
||||
### `GET /.well-known/heimdall-configuration`
|
||||
|
||||
Purpose:
|
||||
|
||||
- publish discovery metadata for app backends and integration code
|
||||
|
||||
Current shape includes:
|
||||
|
||||
- `issuer`
|
||||
- `jwksUri`
|
||||
- `configurationUri`
|
||||
- `oauthStartEndpoint`
|
||||
- `oauthCallbackEndpoint`
|
||||
- `claimIssueEndpoint`
|
||||
- `supportedProviders`
|
||||
- `apps`
|
||||
|
||||
### `GET /v1/apps`
|
||||
|
||||
Purpose:
|
||||
|
||||
- list known app profiles and their capability surfaces
|
||||
|
||||
### `GET /v1/apps/{appSlug}`
|
||||
|
||||
Purpose:
|
||||
|
||||
- return one app profile
|
||||
|
||||
Current app slugs:
|
||||
|
||||
- `repixelizer`
|
||||
- `streampixels`
|
||||
|
||||
Profile fields:
|
||||
|
||||
- `slug`
|
||||
- `displayName`
|
||||
- `profileVersion`
|
||||
- `identityProviders`
|
||||
- `entitlementSources`
|
||||
- `managedConnectionProviders`
|
||||
- `capabilities`
|
||||
|
||||
Capability modes:
|
||||
|
||||
- `shared`
|
||||
fully evaluated by Heimdall from signed facts/grants
|
||||
- `hybrid`
|
||||
requires app-local state after claim verification
|
||||
|
||||
### `POST /v1/oauth/{provider}/start`
|
||||
|
||||
Purpose:
|
||||
|
||||
- begin a provider OAuth flow
|
||||
- mint signed OAuth `state`
|
||||
- return the provider authorization URL
|
||||
|
||||
Supported providers in the skeleton:
|
||||
|
||||
- `discord`
|
||||
- `patreon`
|
||||
- `github`
|
||||
- `twitch`
|
||||
- `youtube`
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"appSlug": "repixelizer",
|
||||
"mode": "sign_in",
|
||||
"returnTo": "https://repixelizer.gamecult.org/app/",
|
||||
"connection": {
|
||||
"kind": "creator",
|
||||
"targetId": "creator:alpha",
|
||||
"summary": "Optional app-local binding hint"
|
||||
},
|
||||
"requestedScopes": ["identify", "guilds"]
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "discord",
|
||||
"appSlug": "repixelizer",
|
||||
"mode": "sign_in",
|
||||
"callbackUrl": "https://heimdall.gamecult.org/v1/oauth/discord/callback",
|
||||
"authorizationUrl": "https://discord.com/oauth2/authorize?...",
|
||||
"stateToken": "<signed-jwt>",
|
||||
"stateExpiresAt": "2026-04-26T12:10:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Failure mode right now:
|
||||
|
||||
- `503 provider_not_configured` when the provider client id is missing
|
||||
|
||||
### `GET /v1/oauth/{provider}/callback`
|
||||
|
||||
Purpose:
|
||||
|
||||
- validate signed OAuth `state`
|
||||
- serve as the landing surface for future code/token exchange
|
||||
|
||||
Current status:
|
||||
|
||||
- validates signed `state`
|
||||
- returns provider errors cleanly
|
||||
- returns `501 token_exchange_not_implemented` after successful state/code
|
||||
validation
|
||||
|
||||
That is deliberate. The contract is landed; provider token exchange and account
|
||||
persistence are next.
|
||||
|
||||
### `POST /v1/apps/{appSlug}/claims/issue`
|
||||
|
||||
Purpose:
|
||||
|
||||
- issue a signed Heimdall access claim for one app after identity/grant facts
|
||||
have already been resolved
|
||||
|
||||
This is currently the concrete claim contract and integration seam for early app
|
||||
binding work.
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"accountId": "acct_repixelizer_001",
|
||||
"displayName": "Meta",
|
||||
"facts": ["discord.allowed_role", "grant.operator"],
|
||||
"linkedIdentities": [
|
||||
{
|
||||
"provider": "discord",
|
||||
"providerUserId": "123456789",
|
||||
"username": "meta"
|
||||
}
|
||||
],
|
||||
"accessRevision": 1,
|
||||
"ttlSeconds": 3600
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"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", "admin_access"],
|
||||
"hybridCapabilities": []
|
||||
}
|
||||
```
|
||||
|
||||
## Access claim shape
|
||||
|
||||
Current claim payload fields:
|
||||
|
||||
- `iss`
|
||||
- `aud`
|
||||
- `sub`
|
||||
- `sid`
|
||||
- `jti`
|
||||
- `iat`
|
||||
- `nbf`
|
||||
- `exp`
|
||||
- `typ`
|
||||
- `account_id`
|
||||
- `access_revision`
|
||||
- `display_name` optional
|
||||
- `app.slug`
|
||||
- `app.profile_version`
|
||||
- `facts`
|
||||
- `capabilities`
|
||||
- `identities`
|
||||
|
||||
Important current rule:
|
||||
|
||||
- Heimdall only grants fully shared capabilities directly
|
||||
- hybrid capabilities still require app-local checks after claim verification
|
||||
|
||||
Examples:
|
||||
|
||||
- Repixelizer `app_access` and `queue_submit` are shared
|
||||
- Repixelizer `job_read_own` still needs local ownership
|
||||
- StreamPixels `viewer_access` can be shared
|
||||
- StreamPixels `creator_access` still needs app-local creator membership
|
||||
|
||||
## Current gaps
|
||||
|
||||
The following are not landed yet:
|
||||
|
||||
- provider token exchange on callback
|
||||
- durable account/session/grant storage
|
||||
- linked-identity persistence
|
||||
- entitlement snapshot persistence
|
||||
- refresh/revocation flows
|
||||
- app-facing middleware packages or reference verifiers
|
||||
|
||||
## Next implementation move
|
||||
|
||||
Build durable storage plus the first real provider path on top of this
|
||||
skeleton:
|
||||
|
||||
- persisted signing key material
|
||||
- accounts and linked identities
|
||||
- real callback exchange for one provider slice
|
||||
- first end-to-end Repixelizer binding
|
||||
|
|
@ -27,16 +27,22 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
|
||||
## Current Orientation
|
||||
|
||||
- this repo is still planning and service-contract work, not a landed auth
|
||||
service yet
|
||||
- this repo now has an initial standalone Heimdall service skeleton
|
||||
- the canonical shared architecture lives in `docs/architecture.md`
|
||||
- the concrete HTTP/JWKS contract lives in `docs/service-contract.md`
|
||||
- app profiles live under `docs/app-profiles/`
|
||||
- the key boundary is explicit:
|
||||
- Heimdall owns OAuth, linking, grants, entitlement refresh, and signed claim
|
||||
issuance
|
||||
- Heimdall owns OAuth, linking, managed provider credential custody, grants,
|
||||
entitlement refresh, and signed claim issuance
|
||||
- app-domain data stays app-owned by default
|
||||
- host apps verify signed claims locally for routine auth instead of calling
|
||||
Heimdall on every request
|
||||
- the landed skeleton already exposes:
|
||||
- `/.well-known/jwks.json`
|
||||
- `/.well-known/heimdall-configuration`
|
||||
- `/v1/oauth/{provider}/start`
|
||||
- `/v1/oauth/{provider}/callback`
|
||||
- `/v1/apps/{appSlug}/claims/issue`
|
||||
- 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
|
||||
|
|
@ -58,13 +64,12 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
Do not continue implementation automatically from a rehydrate-only request.
|
||||
|
||||
If the user asks to continue, the current next move is to define the first
|
||||
Heimdall service contract and skeleton:
|
||||
real provider/storage slice on top of the landed skeleton:
|
||||
|
||||
- OAuth start/callback surfaces
|
||||
- signed session / claim shape
|
||||
- claim verification contract for app backends
|
||||
- entitlement/grant persistence boundaries
|
||||
- Repixelizer and StreamPixels integration seams
|
||||
- durable account / linked-identity / grant persistence
|
||||
- persisted signing key material
|
||||
- first real provider callback/token exchange path
|
||||
- first end-to-end Repixelizer binding
|
||||
|
||||
without dragging app-domain data into the shared layer and without forcing
|
||||
per-request auth round-trips.
|
||||
|
|
|
|||
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "heimdall",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
1434
pnpm-lock.yaml
generated
Normal file
1434
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
172
src/app-profiles.ts
Normal file
172
src/app-profiles.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { type AppSlug, type LinkedIdentityInput, type Provider } from "./contracts.js";
|
||||
|
||||
export type CapabilityMode = "shared" | "hybrid";
|
||||
|
||||
export interface CapabilityDefinition {
|
||||
key: string;
|
||||
mode: CapabilityMode;
|
||||
summary: string;
|
||||
sharedRule?: string;
|
||||
localRequirement?: string;
|
||||
}
|
||||
|
||||
export interface ClaimEvaluationContext {
|
||||
accountId: string;
|
||||
facts: Set<string>;
|
||||
identities: LinkedIdentityInput[];
|
||||
}
|
||||
|
||||
export interface AppProfile {
|
||||
slug: AppSlug;
|
||||
displayName: string;
|
||||
profileVersion: string;
|
||||
identityProviders: Provider[];
|
||||
entitlementSources: Provider[];
|
||||
managedConnectionProviders: Provider[];
|
||||
capabilities: CapabilityDefinition[];
|
||||
evaluateSharedCapabilities(context: ClaimEvaluationContext): string[];
|
||||
}
|
||||
|
||||
function hasAnyFact(facts: Set<string>, values: readonly string[]): boolean {
|
||||
return values.some((value) => facts.has(value));
|
||||
}
|
||||
|
||||
const repixelizerProfile: AppProfile = {
|
||||
slug: "repixelizer",
|
||||
displayName: "Repixelizer",
|
||||
profileVersion: "2026-04-26",
|
||||
identityProviders: ["discord", "patreon"],
|
||||
entitlementSources: ["discord", "patreon"],
|
||||
managedConnectionProviders: [],
|
||||
capabilities: [
|
||||
{
|
||||
key: "app_access",
|
||||
mode: "shared",
|
||||
summary: "May load the protected hosted GUI.",
|
||||
sharedRule:
|
||||
"discord.allowed_role || patreon.allowed_tier || grant.global_member || grant.app_access",
|
||||
},
|
||||
{
|
||||
key: "queue_submit",
|
||||
mode: "shared",
|
||||
summary: "May create a repixelizer job.",
|
||||
sharedRule: "app_access",
|
||||
},
|
||||
{
|
||||
key: "job_read_own",
|
||||
mode: "hybrid",
|
||||
summary: "May read own job state, event stream, and final output.",
|
||||
localRequirement: "Host app must combine app_access with job ownership.",
|
||||
},
|
||||
{
|
||||
key: "job_cancel_own",
|
||||
mode: "hybrid",
|
||||
summary: "May cancel own queued or running job.",
|
||||
localRequirement: "Host app must combine app_access with job ownership.",
|
||||
},
|
||||
{
|
||||
key: "admin_access",
|
||||
mode: "shared",
|
||||
summary: "May inspect grant/admin surfaces.",
|
||||
sharedRule: "grant.operator || grant.admin_access",
|
||||
},
|
||||
],
|
||||
evaluateSharedCapabilities(context) {
|
||||
const capabilities: string[] = [];
|
||||
const appAccess = hasAnyFact(context.facts, [
|
||||
"discord.allowed_role",
|
||||
"patreon.allowed_tier",
|
||||
"grant.global_member",
|
||||
"grant.app_access",
|
||||
]);
|
||||
|
||||
if (appAccess) {
|
||||
capabilities.push("app_access", "queue_submit");
|
||||
}
|
||||
|
||||
if (hasAnyFact(context.facts, ["grant.operator", "grant.admin_access"])) {
|
||||
capabilities.push("admin_access");
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
},
|
||||
};
|
||||
|
||||
const streampixelsProfile: AppProfile = {
|
||||
slug: "streampixels",
|
||||
displayName: "StreamPixels",
|
||||
profileVersion: "2026-04-26",
|
||||
identityProviders: ["twitch", "youtube"],
|
||||
entitlementSources: [],
|
||||
managedConnectionProviders: ["twitch", "youtube"],
|
||||
capabilities: [
|
||||
{
|
||||
key: "viewer_access",
|
||||
mode: "shared",
|
||||
summary: "Authenticated viewer session for control-plane surfaces.",
|
||||
sharedRule: "identity.authenticated",
|
||||
},
|
||||
{
|
||||
key: "creator_access",
|
||||
mode: "hybrid",
|
||||
summary: "Creator-scoped route access.",
|
||||
localRequirement:
|
||||
"Host app must combine authenticated session claims with local creator membership.",
|
||||
},
|
||||
{
|
||||
key: "creator_admin",
|
||||
mode: "hybrid",
|
||||
summary: "Creator-scoped write/admin access.",
|
||||
localRequirement:
|
||||
"Host app must combine authenticated session claims with local creator admin membership.",
|
||||
},
|
||||
{
|
||||
key: "operator_access",
|
||||
mode: "shared",
|
||||
summary: "Global operator access.",
|
||||
sharedRule: "grant.operator",
|
||||
},
|
||||
],
|
||||
evaluateSharedCapabilities(context) {
|
||||
const capabilities: string[] = [];
|
||||
|
||||
if (context.facts.has("identity.authenticated")) {
|
||||
capabilities.push("viewer_access");
|
||||
}
|
||||
|
||||
if (context.facts.has("grant.operator")) {
|
||||
capabilities.push("operator_access");
|
||||
}
|
||||
|
||||
return capabilities;
|
||||
},
|
||||
};
|
||||
|
||||
export const appProfiles: Record<AppSlug, AppProfile> = {
|
||||
repixelizer: repixelizerProfile,
|
||||
streampixels: streampixelsProfile,
|
||||
};
|
||||
|
||||
export function getAppProfile(appSlug: AppSlug): AppProfile {
|
||||
return appProfiles[appSlug];
|
||||
}
|
||||
|
||||
export function serializeAppProfile(profile: AppProfile): Record<string, unknown> {
|
||||
return {
|
||||
slug: profile.slug,
|
||||
displayName: profile.displayName,
|
||||
profileVersion: profile.profileVersion,
|
||||
identityProviders: profile.identityProviders,
|
||||
entitlementSources: profile.entitlementSources,
|
||||
managedConnectionProviders: profile.managedConnectionProviders,
|
||||
capabilities: profile.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
export function supportsProvider(profile: AppProfile, provider: Provider): boolean {
|
||||
return (
|
||||
profile.identityProviders.includes(provider) ||
|
||||
profile.managedConnectionProviders.includes(provider) ||
|
||||
profile.entitlementSources.includes(provider)
|
||||
);
|
||||
}
|
||||
418
src/app.ts
Normal file
418
src/app.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import {
|
||||
appSlugs,
|
||||
connectionKinds,
|
||||
oauthModes,
|
||||
providers,
|
||||
type AccessClaimPayload,
|
||||
type AppSlug,
|
||||
type IssueClaimRequest,
|
||||
type OAuthConnectionBinding,
|
||||
type OAuthStartRequest,
|
||||
type Provider,
|
||||
} from "./contracts.js";
|
||||
import { getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
|
||||
import { type HeimdallConfig, loadConfig } from "./config.js";
|
||||
import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js";
|
||||
import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js";
|
||||
|
||||
interface BuildAppOptions {
|
||||
config?: HeimdallConfig;
|
||||
}
|
||||
|
||||
function nowEpochSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function clampTtlSeconds(value: number, fallback: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Math.max(60, Math.min(86_400, Math.trunc(value)));
|
||||
}
|
||||
|
||||
function normalizeFacts(request: IssueClaimRequest): string[] {
|
||||
const facts = new Set(request.facts ?? []);
|
||||
if (request.accountId.trim() || request.linkedIdentities?.length) {
|
||||
facts.add("identity.authenticated");
|
||||
}
|
||||
return [...facts].sort();
|
||||
}
|
||||
|
||||
function buildDiscovery(config: HeimdallConfig) {
|
||||
return {
|
||||
issuer: config.issuer,
|
||||
jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`,
|
||||
configurationUri: `${config.publicBaseUrl}/.well-known/heimdall-configuration`,
|
||||
oauthStartEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/start`,
|
||||
oauthCallbackEndpoint: `${config.publicBaseUrl}/v1/oauth/{provider}/callback`,
|
||||
claimIssueEndpoint: `${config.publicBaseUrl}/v1/apps/{appSlug}/claims/issue`,
|
||||
supportedProviders: providers.map((provider) => ({
|
||||
key: provider,
|
||||
displayName: providerCatalog[provider].displayName,
|
||||
roles: providerCatalog[provider].roles,
|
||||
})),
|
||||
apps: appSlugs.map((appSlug) => serializeAppProfile(getAppProfile(appSlug))),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOAuthStateToken(options: {
|
||||
config: HeimdallConfig;
|
||||
provider: Provider;
|
||||
appSlug: AppSlug;
|
||||
mode: OAuthStartRequest["mode"];
|
||||
returnTo: string;
|
||||
connection: OAuthConnectionBinding | undefined;
|
||||
keys: ReturnType<typeof createRuntimeKeyMaterial>;
|
||||
}): { token: string; expiresAt: string } {
|
||||
const issuedAt = nowEpochSeconds();
|
||||
const expiresAtEpoch = issuedAt + options.config.stateTtlSeconds;
|
||||
|
||||
const token = signJwt(
|
||||
{
|
||||
iss: options.config.issuer,
|
||||
sub: options.appSlug,
|
||||
aud: options.provider,
|
||||
jti: randomUUID(),
|
||||
iat: issuedAt,
|
||||
nbf: issuedAt,
|
||||
exp: expiresAtEpoch,
|
||||
typ: "heimdall_oauth_state",
|
||||
provider: options.provider,
|
||||
app_slug: options.appSlug,
|
||||
mode: options.mode,
|
||||
return_to: options.returnTo,
|
||||
connection: options.connection ?? null,
|
||||
},
|
||||
options.keys
|
||||
);
|
||||
|
||||
return {
|
||||
token,
|
||||
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
|
||||
const config = options.config ?? loadConfig();
|
||||
const keys = createRuntimeKeyMaterial(config);
|
||||
const app = Fastify({ logger: false });
|
||||
app.decorate("heimdallContext", { config, keys });
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.get("/healthz", async () => ({
|
||||
ok: true,
|
||||
service: config.serviceName,
|
||||
issuer: config.issuer,
|
||||
now: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
app.get("/.well-known/jwks.json", async () => ({
|
||||
keys: [keys.publicJwk],
|
||||
}));
|
||||
|
||||
app.get("/.well-known/heimdall-configuration", async () => buildDiscovery(config));
|
||||
|
||||
app.get("/v1/apps", async () => ({
|
||||
apps: appSlugs.map((appSlug) => serializeAppProfile(getAppProfile(appSlug))),
|
||||
}));
|
||||
|
||||
app.get<{ Params: { appSlug: AppSlug } }>(
|
||||
"/v1/apps/:appSlug",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["appSlug"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
appSlug: { type: "string", enum: [...appSlugs] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request) => serializeAppProfile(getAppProfile(request.params.appSlug))
|
||||
);
|
||||
|
||||
app.post<{ Params: { provider: Provider }; Body: OAuthStartRequest }>(
|
||||
"/v1/oauth/:provider/start",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["provider"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
provider: { type: "string", enum: [...providers] },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["appSlug", "mode", "returnTo"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
appSlug: { type: "string", enum: [...appSlugs] },
|
||||
mode: { type: "string", enum: [...oauthModes] },
|
||||
returnTo: { type: "string", format: "uri" },
|
||||
requestedScopes: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
uniqueItems: true,
|
||||
},
|
||||
connection: {
|
||||
type: "object",
|
||||
required: ["kind", "targetId"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
kind: { type: "string", enum: [...connectionKinds] },
|
||||
targetId: { type: "string", minLength: 1 },
|
||||
summary: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { provider } = request.params;
|
||||
const profile = getAppProfile(request.body.appSlug);
|
||||
|
||||
if (!supportsProvider(profile, provider)) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "provider_not_supported_for_app",
|
||||
provider,
|
||||
appSlug: profile.slug,
|
||||
};
|
||||
}
|
||||
|
||||
const providerConfig = config.providers[provider];
|
||||
if (!providerConfig.clientId) {
|
||||
reply.code(503);
|
||||
return {
|
||||
error: "provider_not_configured",
|
||||
provider,
|
||||
expectedEnv: providerExpectedEnv(provider),
|
||||
};
|
||||
}
|
||||
|
||||
const callbackUrl = `${config.publicBaseUrl}/v1/oauth/${provider}/callback`;
|
||||
const state = buildOAuthStateToken({
|
||||
config,
|
||||
provider,
|
||||
appSlug: profile.slug,
|
||||
mode: request.body.mode,
|
||||
returnTo: request.body.returnTo,
|
||||
connection: request.body.connection,
|
||||
keys,
|
||||
});
|
||||
|
||||
reply.code(201);
|
||||
return {
|
||||
provider,
|
||||
appSlug: profile.slug,
|
||||
mode: request.body.mode,
|
||||
callbackUrl,
|
||||
authorizationUrl: buildAuthorizationUrl({
|
||||
provider,
|
||||
clientId: providerConfig.clientId,
|
||||
redirectUri: callbackUrl,
|
||||
state: state.token,
|
||||
requestedScopes: request.body.requestedScopes,
|
||||
}),
|
||||
stateToken: state.token,
|
||||
stateExpiresAt: state.expiresAt,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{
|
||||
Params: { provider: Provider };
|
||||
Querystring: { code?: string; state?: string; error?: string; error_description?: string };
|
||||
}>(
|
||||
"/v1/oauth/:provider/callback",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["provider"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
provider: { type: "string", enum: [...providers] },
|
||||
},
|
||||
},
|
||||
querystring: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
code: { type: "string" },
|
||||
state: { type: "string" },
|
||||
error: { type: "string" },
|
||||
error_description: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!request.query.state) {
|
||||
reply.code(400);
|
||||
return { error: "missing_state" };
|
||||
}
|
||||
|
||||
const verification = verifyJwt(request.query.state, keys.publicKey);
|
||||
if (!verification.valid) {
|
||||
reply.code(400);
|
||||
return { error: "invalid_state", detail: verification.error };
|
||||
}
|
||||
|
||||
const statePayload = verification.payload;
|
||||
if (statePayload.typ !== "heimdall_oauth_state" || statePayload.provider !== request.params.provider) {
|
||||
reply.code(400);
|
||||
return { error: "state_provider_mismatch" };
|
||||
}
|
||||
|
||||
if (request.query.error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "provider_error",
|
||||
provider: request.params.provider,
|
||||
providerError: request.query.error,
|
||||
providerErrorDescription: request.query.error_description,
|
||||
};
|
||||
}
|
||||
|
||||
if (!request.query.code) {
|
||||
reply.code(400);
|
||||
return { error: "missing_code" };
|
||||
}
|
||||
|
||||
reply.code(501);
|
||||
return {
|
||||
error: "token_exchange_not_implemented",
|
||||
provider: request.params.provider,
|
||||
appSlug: statePayload.app_slug,
|
||||
mode: statePayload.mode,
|
||||
returnTo: statePayload.return_to,
|
||||
connection: statePayload.connection,
|
||||
codeReceived: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: IssueClaimRequest }>(
|
||||
"/v1/apps/:appSlug/claims/issue",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["appSlug"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
appSlug: { type: "string", enum: [...appSlugs] },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["accountId"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
accountId: { type: "string", minLength: 1 },
|
||||
sessionId: { type: "string", minLength: 1 },
|
||||
displayName: { type: "string", minLength: 1 },
|
||||
facts: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
uniqueItems: true,
|
||||
},
|
||||
accessRevision: { type: "integer", minimum: 1 },
|
||||
ttlSeconds: { type: "integer", minimum: 60, maximum: 86400 },
|
||||
linkedIdentities: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
required: ["provider", "providerUserId"],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
provider: { type: "string", enum: [...providers] },
|
||||
providerUserId: { type: "string", minLength: 1 },
|
||||
username: { type: "string" },
|
||||
displayName: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const profile = getAppProfile(request.params.appSlug);
|
||||
const facts = normalizeFacts(request.body);
|
||||
const factSet = new Set(facts);
|
||||
const linkedIdentities = request.body.linkedIdentities ?? [];
|
||||
const sharedCapabilities = profile.evaluateSharedCapabilities({
|
||||
accountId: request.body.accountId,
|
||||
facts: factSet,
|
||||
identities: linkedIdentities,
|
||||
});
|
||||
|
||||
const issuedAt = nowEpochSeconds();
|
||||
const ttlSeconds = clampTtlSeconds(request.body.ttlSeconds ?? config.sessionTtlSeconds, config.sessionTtlSeconds);
|
||||
const expiresAtEpoch = issuedAt + ttlSeconds;
|
||||
const sessionId = request.body.sessionId ?? randomUUID();
|
||||
const accessClaim: AccessClaimPayload = {
|
||||
iss: config.issuer,
|
||||
aud: profile.slug,
|
||||
sub: request.body.accountId,
|
||||
sid: sessionId,
|
||||
jti: randomUUID(),
|
||||
iat: issuedAt,
|
||||
nbf: issuedAt,
|
||||
exp: expiresAtEpoch,
|
||||
typ: "heimdall_access",
|
||||
account_id: request.body.accountId,
|
||||
access_revision: request.body.accessRevision ?? 1,
|
||||
app: {
|
||||
slug: profile.slug,
|
||||
profile_version: profile.profileVersion,
|
||||
},
|
||||
facts,
|
||||
capabilities: sharedCapabilities,
|
||||
identities: linkedIdentities,
|
||||
};
|
||||
|
||||
if (request.body.displayName) {
|
||||
accessClaim.display_name = request.body.displayName;
|
||||
}
|
||||
|
||||
reply.code(201);
|
||||
return {
|
||||
session: {
|
||||
accountId: request.body.accountId,
|
||||
sessionId,
|
||||
appSlug: profile.slug,
|
||||
accessRevision: accessClaim.access_revision,
|
||||
expiresAt: new Date(expiresAtEpoch * 1000).toISOString(),
|
||||
},
|
||||
accessToken: signJwt(accessClaim as unknown as Record<string, unknown>, keys),
|
||||
claimSet: accessClaim,
|
||||
verification: {
|
||||
issuer: config.issuer,
|
||||
jwksUri: `${config.publicBaseUrl}/.well-known/jwks.json`,
|
||||
alg: keys.alg,
|
||||
kid: keys.kid,
|
||||
},
|
||||
sharedCapabilities,
|
||||
hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
81
src/config.ts
Normal file
81
src/config.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { providers, type Provider } from "./contracts.js";
|
||||
|
||||
export interface ProviderClientConfig {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
export interface HeimdallConfig {
|
||||
serviceName: string;
|
||||
host: string;
|
||||
port: number;
|
||||
publicBaseUrl: string;
|
||||
issuer: string;
|
||||
sessionTtlSeconds: number;
|
||||
stateTtlSeconds: number;
|
||||
signingPrivateKeyPem?: string;
|
||||
signingKeyId?: string;
|
||||
providers: Record<Provider, ProviderClientConfig>;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function readInt(envValue: string | undefined, fallback: number): number {
|
||||
if (!envValue) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(envValue, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): ProviderClientConfig {
|
||||
const prefix = `GC_ACCESS_PROVIDER_${provider.toUpperCase()}`;
|
||||
const config: ProviderClientConfig = {};
|
||||
const clientId = env[`${prefix}_CLIENT_ID`];
|
||||
const clientSecret = env[`${prefix}_CLIENT_SECRET`];
|
||||
|
||||
if (clientId) {
|
||||
config.clientId = clientId;
|
||||
}
|
||||
|
||||
if (clientSecret) {
|
||||
config.clientSecret = clientSecret;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig {
|
||||
const host = env.HOST ?? "127.0.0.1";
|
||||
const port = readInt(env.PORT, 4100);
|
||||
const publicBaseUrl = trimTrailingSlash(env.GC_ACCESS_BASE_URL ?? `http://${host}:${port}`);
|
||||
const issuer = trimTrailingSlash(env.GC_ACCESS_ISSUER ?? publicBaseUrl);
|
||||
const providersConfig = Object.fromEntries(
|
||||
providers.map((provider) => [provider, readProviderConfig(env, provider)])
|
||||
) as Record<Provider, ProviderClientConfig>;
|
||||
|
||||
const config: HeimdallConfig = {
|
||||
serviceName: "heimdall",
|
||||
host,
|
||||
port,
|
||||
publicBaseUrl,
|
||||
issuer,
|
||||
sessionTtlSeconds: readInt(env.GC_ACCESS_SESSION_TTL_SECONDS, 3600),
|
||||
stateTtlSeconds: readInt(env.GC_ACCESS_STATE_TTL_SECONDS, 600),
|
||||
providers: providersConfig,
|
||||
};
|
||||
|
||||
const signingPrivateKeyPem = env.GC_ACCESS_SIGNING_PRIVATE_KEY_PEM?.replace(/\\n/g, "\n");
|
||||
if (signingPrivateKeyPem) {
|
||||
config.signingPrivateKeyPem = signingPrivateKeyPem;
|
||||
}
|
||||
|
||||
if (env.GC_ACCESS_SIGNING_KEY_ID) {
|
||||
config.signingKeyId = env.GC_ACCESS_SIGNING_KEY_ID;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
68
src/contracts.ts
Normal file
68
src/contracts.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
export const providers = ["discord", "patreon", "github", "twitch", "youtube"] as const;
|
||||
|
||||
export type Provider = (typeof providers)[number];
|
||||
|
||||
export const appSlugs = ["repixelizer", "streampixels"] as const;
|
||||
|
||||
export type AppSlug = (typeof appSlugs)[number];
|
||||
|
||||
export const oauthModes = ["sign_in", "link", "connect"] as const;
|
||||
|
||||
export type OAuthMode = (typeof oauthModes)[number];
|
||||
|
||||
export const connectionKinds = ["account", "creator", "workspace", "project", "channel"] as const;
|
||||
|
||||
export type ConnectionKind = (typeof connectionKinds)[number];
|
||||
|
||||
export interface OAuthConnectionBinding {
|
||||
kind: ConnectionKind;
|
||||
targetId: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface OAuthStartRequest {
|
||||
appSlug: AppSlug;
|
||||
mode: OAuthMode;
|
||||
returnTo: string;
|
||||
connection?: OAuthConnectionBinding;
|
||||
requestedScopes?: string[];
|
||||
}
|
||||
|
||||
export interface LinkedIdentityInput {
|
||||
provider: Provider;
|
||||
providerUserId: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface IssueClaimRequest {
|
||||
accountId: string;
|
||||
sessionId?: string;
|
||||
displayName?: string;
|
||||
facts?: string[];
|
||||
linkedIdentities?: LinkedIdentityInput[];
|
||||
accessRevision?: number;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface AccessClaimPayload {
|
||||
iss: string;
|
||||
aud: AppSlug;
|
||||
sub: string;
|
||||
sid: string;
|
||||
jti: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
typ: "heimdall_access";
|
||||
account_id: string;
|
||||
access_revision: number;
|
||||
display_name?: string;
|
||||
app: {
|
||||
slug: AppSlug;
|
||||
profile_version: string;
|
||||
};
|
||||
facts: string[];
|
||||
capabilities: string[];
|
||||
identities: LinkedIdentityInput[];
|
||||
}
|
||||
13
src/index.ts
Normal file
13
src/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { buildApp } from "./app.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
|
||||
const config = loadConfig();
|
||||
const app = await buildApp({ config });
|
||||
|
||||
try {
|
||||
await app.listen({ host: config.host, port: config.port });
|
||||
app.log.info(`Heimdall listening on ${config.host}:${config.port}`);
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
101
src/providers.ts
Normal file
101
src/providers.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { type Provider } from "./contracts.js";
|
||||
|
||||
export type ProviderRole = "identity" | "entitlement" | "managed_credential";
|
||||
|
||||
export interface ProviderDescriptor {
|
||||
key: Provider;
|
||||
displayName: string;
|
||||
authorizationEndpoint: string;
|
||||
defaultScopes: string[];
|
||||
roles: ProviderRole[];
|
||||
clientIdEnv: string;
|
||||
clientSecretEnv: string;
|
||||
additionalAuthorizationParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const providerCatalog: Record<Provider, ProviderDescriptor> = {
|
||||
discord: {
|
||||
key: "discord",
|
||||
displayName: "Discord",
|
||||
authorizationEndpoint: "https://discord.com/oauth2/authorize",
|
||||
defaultScopes: ["identify", "guilds"],
|
||||
roles: ["identity", "entitlement"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET",
|
||||
},
|
||||
patreon: {
|
||||
key: "patreon",
|
||||
displayName: "Patreon",
|
||||
authorizationEndpoint: "https://www.patreon.com/oauth2/authorize",
|
||||
defaultScopes: ["identity", "identity[email]", "campaigns.members"],
|
||||
roles: ["identity", "entitlement"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_PATREON_CLIENT_SECRET",
|
||||
},
|
||||
github: {
|
||||
key: "github",
|
||||
displayName: "GitHub",
|
||||
authorizationEndpoint: "https://github.com/login/oauth/authorize",
|
||||
defaultScopes: ["read:user", "user:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_GITHUB_CLIENT_SECRET",
|
||||
},
|
||||
twitch: {
|
||||
key: "twitch",
|
||||
displayName: "Twitch",
|
||||
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
|
||||
defaultScopes: ["user:read:email"],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_TWITCH_CLIENT_SECRET",
|
||||
},
|
||||
youtube: {
|
||||
key: "youtube",
|
||||
displayName: "YouTube",
|
||||
authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
defaultScopes: [
|
||||
"openid",
|
||||
"email",
|
||||
"profile",
|
||||
"https://www.googleapis.com/auth/youtube.readonly",
|
||||
],
|
||||
roles: ["identity", "managed_credential"],
|
||||
clientIdEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_ID",
|
||||
clientSecretEnv: "GC_ACCESS_PROVIDER_YOUTUBE_CLIENT_SECRET",
|
||||
additionalAuthorizationParams: {
|
||||
access_type: "offline",
|
||||
include_granted_scopes: "true",
|
||||
prompt: "consent",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function buildAuthorizationUrl(options: {
|
||||
provider: Provider;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
state: string;
|
||||
requestedScopes: string[] | undefined;
|
||||
}): string {
|
||||
const descriptor = providerCatalog[options.provider];
|
||||
const url = new URL(descriptor.authorizationEndpoint);
|
||||
const scopes = options.requestedScopes?.length ? options.requestedScopes : descriptor.defaultScopes;
|
||||
|
||||
url.searchParams.set("client_id", options.clientId);
|
||||
url.searchParams.set("redirect_uri", options.redirectUri);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("scope", scopes.join(" "));
|
||||
url.searchParams.set("state", options.state);
|
||||
|
||||
for (const [key, value] of Object.entries(descriptor.additionalAuthorizationParams ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function providerExpectedEnv(provider: Provider): string[] {
|
||||
const descriptor = providerCatalog[provider];
|
||||
return [descriptor.clientIdEnv, descriptor.clientSecretEnv];
|
||||
}
|
||||
120
src/signing.ts
Normal file
120
src/signing.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import {
|
||||
createHash,
|
||||
createPrivateKey,
|
||||
createPublicKey,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
verify,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { type HeimdallConfig } from "./config.js";
|
||||
|
||||
type Jwk = {
|
||||
crv?: string;
|
||||
kty?: string;
|
||||
x?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export interface RuntimeKeyMaterial {
|
||||
alg: "EdDSA";
|
||||
kid: string;
|
||||
privateKey: KeyObject;
|
||||
publicKey: KeyObject;
|
||||
publicJwk: Jwk & { alg: "EdDSA"; kid: string; use: "sig" };
|
||||
}
|
||||
|
||||
function encodeBase64Url(input: string | Buffer): string {
|
||||
return Buffer.from(input).toString("base64url");
|
||||
}
|
||||
|
||||
function decodeBase64Url(input: string): string {
|
||||
return Buffer.from(input, "base64url").toString("utf8");
|
||||
}
|
||||
|
||||
function buildKeyId(publicJwk: Jwk, explicitKeyId?: string): string {
|
||||
if (explicitKeyId) {
|
||||
return explicitKeyId;
|
||||
}
|
||||
|
||||
const thumbprint = createHash("sha256")
|
||||
.update(JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x }))
|
||||
.digest("hex");
|
||||
return `ed25519-${thumbprint.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function createRuntimeKeyMaterial(config: HeimdallConfig): RuntimeKeyMaterial {
|
||||
const privateKey = config.signingPrivateKeyPem
|
||||
? createPrivateKey(config.signingPrivateKeyPem)
|
||||
: generateKeyPairSync("ed25519").privateKey;
|
||||
const publicKey = createPublicKey(privateKey);
|
||||
const exportedJwk = publicKey.export({ format: "jwk" }) as Jwk;
|
||||
const kid = buildKeyId(exportedJwk, config.signingKeyId);
|
||||
|
||||
return {
|
||||
alg: "EdDSA",
|
||||
kid,
|
||||
privateKey,
|
||||
publicKey,
|
||||
publicJwk: {
|
||||
...exportedJwk,
|
||||
alg: "EdDSA",
|
||||
kid,
|
||||
use: "sig",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function signJwt(payload: Record<string, unknown>, keys: RuntimeKeyMaterial): string {
|
||||
const header = {
|
||||
alg: keys.alg,
|
||||
kid: keys.kid,
|
||||
typ: "JWT",
|
||||
};
|
||||
|
||||
const encodedHeader = encodeBase64Url(JSON.stringify(header));
|
||||
const encodedPayload = encodeBase64Url(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = sign(null, Buffer.from(signingInput, "utf8"), keys.privateKey);
|
||||
|
||||
return `${signingInput}.${encodeBase64Url(signature)}`;
|
||||
}
|
||||
|
||||
export function decodeJwt(token: string): {
|
||||
header: Record<string, unknown>;
|
||||
payload: Record<string, unknown>;
|
||||
} {
|
||||
const [encodedHeader, encodedPayload] = token.split(".");
|
||||
if (!encodedHeader || !encodedPayload) {
|
||||
throw new Error("Malformed JWT.");
|
||||
}
|
||||
|
||||
return {
|
||||
header: JSON.parse(decodeBase64Url(encodedHeader)) as Record<string, unknown>,
|
||||
payload: JSON.parse(decodeBase64Url(encodedPayload)) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyJwt(
|
||||
token: string,
|
||||
publicKey: KeyObject
|
||||
): { valid: true; header: Record<string, unknown>; payload: Record<string, unknown> } | {
|
||||
valid: false;
|
||||
error: string;
|
||||
} {
|
||||
const [encodedHeader, encodedPayload, encodedSignature] = token.split(".");
|
||||
if (!encodedHeader || !encodedPayload || !encodedSignature) {
|
||||
return { valid: false, error: "Malformed JWT." };
|
||||
}
|
||||
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = Buffer.from(encodedSignature, "base64url");
|
||||
const valid = verify(null, Buffer.from(signingInput, "utf8"), publicKey, signature);
|
||||
|
||||
if (!valid) {
|
||||
return { valid: false, error: "Invalid JWT signature." };
|
||||
}
|
||||
|
||||
const { header, payload } = decodeJwt(token);
|
||||
return { valid: true, header, payload };
|
||||
}
|
||||
|
|
@ -3,3 +3,4 @@
|
|||
{"ts":"2026-04-26T00:00:00Z","type":"boundary","status":"accepted","note":"StreamPixels can reuse the same auth/control-plane backbone, and Heimdall may own OAuth/token custody for shared provider connections, but streamer-audience data, creator memberships, creator-side connection bindings, and runtime state remain StreamPixels-owned."}
|
||||
{"ts":"2026-04-26T06:00:00Z","type":"design","status":"accepted","note":"The cross-runtime split and current Yggdrasil topology changed the first implementation shape from an embedded shared package to Heimdall: a shared auth authority service whose signed claims are verified locally by each app."}
|
||||
{"ts": "2026-04-26T11:00:02+00:00", "type": "design", "status": "accepted", "note": "Heimdall's provider model splits shared OAuth and credential custody from app-owned binding semantics and runtime use. Different provider mixes change adapter inventory and claim vocabulary, not the core service boundary."}
|
||||
{"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."}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
project:
|
||||
name: Heimdall
|
||||
status: active
|
||||
phase: service_topology_alignment
|
||||
phase: service_contract_skeleton
|
||||
premise: >
|
||||
Build Heimdall as one reusable GameCult auth authority service for OAuth,
|
||||
linked identities, local sessions, grants, and capability claims without
|
||||
|
|
@ -59,6 +59,7 @@ active_subgoals:
|
|||
|
||||
accepted_design:
|
||||
- `docs/architecture.md` is the canonical shared architecture note.
|
||||
- `docs/service-contract.md` records the concrete HTTP/JWKS contract of the landed skeleton.
|
||||
- `docs/app-profiles/*.md` are thin app bindings, not duplicate brains.
|
||||
- Heimdall owns provider OAuth, identity linking, signed local sessions, grants, entitlement refresh, capability evaluation, and audit events.
|
||||
- Host apps own their domain data, runtime state, and product-specific resources.
|
||||
|
|
@ -66,11 +67,13 @@ accepted_design:
|
|||
- A shared Heimdall store, if it exists, should hold auth/control-plane truth only.
|
||||
- Repixelizer is the first auth-blank hosted-app binding.
|
||||
- StreamPixels is the migration case that proves shared authority must not imply audience-data centralization.
|
||||
- The first service skeleton is a standalone TypeScript/Fastify app with Ed25519 JWT signing, JWKS discovery, signed OAuth `state`, and app-profile-driven shared capability evaluation.
|
||||
|
||||
landed_baseline:
|
||||
- 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.
|
||||
- Initial Heimdall service skeleton landed with discovery, JWKS, OAuth start/callback contract surfaces, and claim issuance scaffolding.
|
||||
|
||||
rejected_paths:
|
||||
- name: per_app_auth_copy_paste
|
||||
|
|
@ -81,5 +84,5 @@ rejected_paths:
|
|||
reason: reusing auth mechanics does not justify centralizing app-domain audience or product data
|
||||
|
||||
current_status:
|
||||
summary: The shared hosted-access planning work now lives in Heimdall, a service-first auth authority repo with a Yggdrasil-hosted topology in mind. The key boundary is explicit: Heimdall owns OAuth, linking, grants, entitlement refresh, and signed claims; app-domain data remains app-owned; routine route authorization stays local after claim verification.
|
||||
next_action: Start the first implementation-oriented pass by defining Heimdall's service contract and skeleton: OAuth start/callback surfaces, signed session and claim payloads, claim verification material for app backends, and the first Repixelizer and StreamPixels integration seams.
|
||||
summary: Heimdall now has a first standalone service skeleton: TypeScript/Fastify runtime, Ed25519 JWT signing, JWKS/discovery exposure, signed OAuth start-state scaffolding, and app-profile-driven claim issuance for Repixelizer and StreamPixels. 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 durable persistence and the first real provider path on top of the skeleton: persisted signing key material, accounts/linked identities/grants, a real provider callback/token exchange slice, and the first end-to-end Repixelizer binding.
|
||||
|
|
|
|||
162
tests/app.test.ts
Normal file
162
tests/app.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type FastifyInstance } from "fastify";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { type HeimdallConfig } from "../src/config.js";
|
||||
import { decodeJwt, verifyJwt } from "../src/signing.js";
|
||||
|
||||
function createTestConfig(): HeimdallConfig {
|
||||
return {
|
||||
serviceName: "heimdall",
|
||||
host: "127.0.0.1",
|
||||
port: 4100,
|
||||
publicBaseUrl: "https://heimdall.gamecult.org",
|
||||
issuer: "https://heimdall.gamecult.org",
|
||||
sessionTtlSeconds: 3600,
|
||||
stateTtlSeconds: 600,
|
||||
providers: {
|
||||
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
||||
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
|
||||
github: { clientId: "github-client", clientSecret: "github-secret" },
|
||||
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
||||
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
||||
|
||||
function getPublicKey(app: FastifyInstance) {
|
||||
return (
|
||||
app as FastifyInstance & {
|
||||
heimdallContext: {
|
||||
keys: {
|
||||
publicKey: Parameters<typeof verifyJwt>[1];
|
||||
};
|
||||
};
|
||||
}
|
||||
).heimdallContext.keys.publicKey;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
while (apps.length) {
|
||||
await apps.pop()?.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("Heimdall service skeleton", () => {
|
||||
it("serves discovery and JWKS metadata", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const discoveryResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/.well-known/heimdall-configuration",
|
||||
});
|
||||
expect(discoveryResponse.statusCode).toBe(200);
|
||||
expect(discoveryResponse.json()).toEqual(
|
||||
expect.objectContaining({
|
||||
issuer: "https://heimdall.gamecult.org",
|
||||
jwksUri: "https://heimdall.gamecult.org/.well-known/jwks.json",
|
||||
})
|
||||
);
|
||||
|
||||
const jwksResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/.well-known/jwks.json",
|
||||
});
|
||||
expect(jwksResponse.statusCode).toBe(200);
|
||||
expect(jwksResponse.json()).toEqual(
|
||||
expect.objectContaining({
|
||||
keys: [
|
||||
expect.objectContaining({
|
||||
alg: "EdDSA",
|
||||
kid: expect.any(String),
|
||||
use: "sig",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("builds signed OAuth start state for supported providers", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/oauth/discord/start",
|
||||
payload: {
|
||||
appSlug: "repixelizer",
|
||||
mode: "sign_in",
|
||||
returnTo: "https://repixelizer.gamecult.org/app/",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
||||
|
||||
const decoded = decodeJwt(payload.stateToken);
|
||||
expect(decoded.payload).toEqual(
|
||||
expect.objectContaining({
|
||||
typ: "heimdall_oauth_state",
|
||||
provider: "discord",
|
||||
app_slug: "repixelizer",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("issues signed claims and evaluates shared app capabilities", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/v1/apps/repixelizer/claims/issue",
|
||||
payload: {
|
||||
accountId: "acct_repixelizer_001",
|
||||
displayName: "Meta",
|
||||
facts: ["discord.allowed_role", "grant.operator"],
|
||||
linkedIdentities: [
|
||||
{
|
||||
provider: "discord",
|
||||
providerUserId: "123456789",
|
||||
username: "meta",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect(payload.sharedCapabilities).toEqual(
|
||||
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
|
||||
);
|
||||
|
||||
const verified = verifyJwt(payload.accessToken, getPublicKey(app));
|
||||
expect(verified.valid).toBe(true);
|
||||
|
||||
const tokenDecoded = decodeJwt(payload.accessToken);
|
||||
expect(tokenDecoded.payload.capabilities).toEqual(
|
||||
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {
|
||||
const app = await buildApp({ config: createTestConfig() });
|
||||
apps.push(app);
|
||||
|
||||
const profileResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/apps/streampixels",
|
||||
});
|
||||
|
||||
expect(profileResponse.statusCode).toBe(200);
|
||||
expect(profileResponse.json()).toEqual(
|
||||
expect.objectContaining({
|
||||
slug: "streampixels",
|
||||
managedConnectionProviders: ["twitch", "youtube"],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2024"],
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Loading…
Reference in a new issue