diff --git a/README.md b/README.md index cb5e1f4..06daf7b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Heimdall -Heimdall is the planned shared auth authority for GameCult-hosted experiments. +Heimdall is the shared auth authority taking shape for GameCult-hosted +experiments. The point is painfully simple: @@ -13,7 +14,7 @@ The point is painfully simple: ## Current Shape -This repo now has an initial standalone service skeleton. +This repo now has a stateful first slice instead of a decorative skeleton. Landed right now: @@ -21,14 +22,20 @@ Landed right now: - Ed25519 JWT signing plus `/.well-known/jwks.json` - signed OAuth start-state scaffolding for Discord, Patreon, GitHub, Twitch, and YouTube +- durable auth/control-plane storage with in-memory and Postgres backends +- account, linked-identity, session, grant, entitlement snapshot, and audit + persistence +- a real Discord OAuth callback path for the first Repixelizer access flow - 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 +- non-ephemeral signing key handling +- real token encryption at rest instead of "encrypted" field names doing + cosplay +- end-to-end app integrations in consumer repos +- refresh/revocation flows and admin surfaces Canonical docs: diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 55f28cd..7cfa4c9 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -23,6 +23,10 @@ What exists right now is: - Ed25519 JWT signing plus JWKS/discovery exposure - OAuth start/callback contract surfaces with signed `state` - app-profile-driven claim issuance for Repixelizer and StreamPixels +- in-memory/Postgres auth-control-plane storage +- persisted accounts, linked identities, sessions, grants, entitlement + snapshots, and audit records +- a real Discord provider callback slice for Repixelizer access - explicit state/doctrine scaffolding so the plan does not immediately turn into soup @@ -54,6 +58,7 @@ Goals: - define OAuth start/callback/link surfaces - define grant and entitlement persistence boundaries - define how app backends verify claims locally + This is now the next missing seam, not a hypothetical one. ### 3. Make Repixelizer the first auth-blank consumer @@ -94,8 +99,8 @@ Landed in the first pass: Still to do in this phase: -- durable grant / entitlement / audit record shapes -- persistence-backed account and linked-identity schema details +- production-safe signing key persistence +- token custody hardening instead of plain placeholder storage ### Phase 2: Heimdall service skeleton @@ -108,16 +113,16 @@ Landed in the first pass: Next in this phase: -- account / linked-identity persistence -- provider token exchange and refresh -- audit logging +- provider refresh/revocation flows - non-ephemeral signing key handling +- token encryption at rest ### Phase 3: Repixelizer binding - integrate Heimdall into the hosted demo - 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 ### Phase 4: StreamPixels authority migration diff --git a/docs/service-contract.md b/docs/service-contract.md index 9904a77..12613db 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -1,10 +1,10 @@ # Heimdall Service Contract -This file describes the first landed Heimdall service skeleton. +This file describes the first landed Heimdall service slice. 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. +claim that every provider, consumer binding, or production-hardening concern is +done yet. ## Current stack @@ -28,6 +28,7 @@ Response: "ok": true, "service": "heimdall", "issuer": "https://heimdall.gamecult.org", + "storageBackend": "memory", "now": "2026-04-26T12:00:00.000Z" } ``` @@ -157,11 +158,15 @@ 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. +- 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 +- other configured providers still return "not implemented" at the runtime + adapter layer until their callback paths are added ### `POST /v1/apps/{appSlug}/claims/issue` @@ -254,19 +259,17 @@ Examples: The following are not landed yet: -- provider token exchange on callback -- durable account/session/grant storage -- linked-identity persistence -- entitlement snapshot persistence +- non-ephemeral signing key handling +- actual token encryption at rest and secret-management policy - refresh/revocation flows - app-facing middleware packages or reference verifiers +- admin/grant management surfaces ## Next implementation move -Build durable storage plus the first real provider path on top of this -skeleton: +Build the first consumer seam and harden the custody story: - persisted signing key material -- accounts and linked identities -- real callback exchange for one provider slice -- first end-to-end Repixelizer binding +- real token encryption at rest +- a reference verifier / middleware contract for app backends +- the first Repixelizer consumer binding that trusts Heimdall claims locally diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index b7214ca..0f153a8 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -27,7 +27,7 @@ Do not trust this file for the exact live HEAD. Always check git. ## Current Orientation -- this repo now has an initial standalone Heimdall service skeleton +- this repo now has a first stateful Heimdall service slice - 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/` @@ -38,11 +38,15 @@ Do not trust this file for the exact live HEAD. Always check git. - host apps verify signed claims locally for routine auth instead of calling Heimdall on every request - the landed skeleton already exposes: +- the landed service now exposes: - `/.well-known/jwks.json` - `/.well-known/heimdall-configuration` - `/v1/oauth/{provider}/start` - `/v1/oauth/{provider}/callback` - `/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 - 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 @@ -63,13 +67,14 @@ 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 -real provider/storage slice on top of the landed skeleton: +If the user asks to continue, the current next move is to harden and consume +the landed slice: -- durable account / linked-identity / grant persistence - persisted signing key material -- first real provider callback/token exchange path -- first end-to-end Repixelizer binding +- real token encryption at rest +- a reference verifier / middleware seam for app backends +- the first Repixelizer consumer integration that trusts Heimdall claims + locally without dragging app-domain data into the shared layer and without forcing per-request auth round-trips. diff --git a/package.json b/package.json index cd4e73c..626b3a0 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,13 @@ }, "dependencies": { "@fastify/cors": "^11.2.0", - "fastify": "^5.8.5" + "fastify": "^5.8.5", + "pg": "^8.20.0" }, "devDependencies": { "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", + "pg-mem": "^3.0.14", "tsx": "^4.21.0", "typescript": "^6.0.3", "vitest": "^4.1.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0724627..e56ffb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,10 +14,19 @@ importers: fastify: specifier: ^5.8.5 version: 5.8.5 + pg: + specifier: ^8.20.0 + version: 8.20.0 devDependencies: '@types/node': specifier: ^25.6.0 version: 25.6.0 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + pg-mem: + specifier: ^3.0.14 + version: 3.0.14 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -347,6 +356,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@vitest/expect@4.1.5': resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} @@ -401,10 +413,25 @@ packages: avvio@9.2.0: resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -412,6 +439,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -420,9 +451,28 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -477,19 +527,61 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + immutable@4.3.8: + resolution: {integrity: sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==} + ipaddr.js@2.3.0: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify@1.3.0: + resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} + engines: {node: '>= 0.4'} + + jsonify@0.0.1: + resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -567,14 +659,40 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -585,6 +703,78 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.12.0: + resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-mem@3.0.14: + resolution: {integrity: sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==} + peerDependencies: + '@mikro-orm/core': '>=4.5.3' + '@mikro-orm/postgresql': '>=4.5.3' + knex: '>=0.20' + kysely: '>=0.26' + mikro-orm: '*' + pg-promise: '>=10.8.7' + pg-server: ^0.1.5 + postgres: ^3.4.4 + slonik: '>=23.0.1' + typeorm: '>=0.2.29' + peerDependenciesMeta: + '@mikro-orm/core': + optional: true + '@mikro-orm/postgresql': + optional: true + knex: + optional: true + kysely: + optional: true + mikro-orm: + optional: true + pg-promise: + optional: true + pg-server: + optional: true + postgres: + optional: true + slonik: + optional: true + typeorm: + optional: true + + pg-pool@3.13.0: + resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.20.0: + resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + pgsql-ast-parser@12.0.2: + resolution: {integrity: sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -606,6 +796,22 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + process-warning@4.0.1: resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} @@ -615,6 +821,13 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -626,6 +839,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + ret@0.5.0: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} @@ -661,6 +878,10 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -809,6 +1030,13 @@ packages: engines: {node: '>=8'} hasBin: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + snapshots: '@emnapi/core@1.10.0': @@ -1017,6 +1245,12 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.6.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@vitest/expect@4.1.5': dependencies: '@standard-schema/spec': 1.1.0 @@ -1080,18 +1314,59 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + chai@6.2.2: {} + commander@2.20.3: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + dequal@2.0.3: {} detect-libc@2.1.2: {} + discontinuous-range@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -1183,18 +1458,66 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + functional-red-black-tree@1.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 + gopd@1.2.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + immutable@4.3.8: {} + ipaddr.js@2.3.0: {} + isarray@2.0.5: {} + json-schema-ref-resolver@3.0.0: dependencies: dequal: 2.0.3 json-schema-traverse@1.0.0: {} + json-stable-stringify@1.3.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + isarray: 2.0.5 + jsonify: 0.0.1 + object-keys: 1.1.1 + + jsonify@0.0.1: {} + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -1250,18 +1573,89 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + moment@2.30.1: {} + + moo@0.5.3: {} + nanoid@3.3.11: {} + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + + object-hash@2.2.0: {} + + object-keys@1.1.1: {} + obug@2.1.1: {} on-exit-leak-free@2.1.2: {} pathe@2.0.3: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.12.0: {} + + pg-int8@1.0.1: {} + + pg-mem@3.0.14: + dependencies: + functional-red-black-tree: 1.0.1 + immutable: 4.3.8 + json-stable-stringify: 1.3.0 + lru-cache: 6.0.0 + moment: 2.30.1 + object-hash: 2.2.0 + pgsql-ast-parser: 12.0.2 + + pg-pool@3.13.0(pg@8.20.0): + dependencies: + pg: 8.20.0 + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.20.0: + dependencies: + pg-connection-string: 2.12.0 + pg-pool: 3.13.0(pg@8.20.0) + pg-protocol: 1.13.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + pgsql-ast-parser@12.0.2: + dependencies: + moo: 0.5.3 + nearley: 2.20.1 + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -1292,18 +1686,37 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + process-warning@4.0.1: {} process-warning@5.0.0: {} quick-format-unescaped@4.0.4: {} + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + real-require@0.2.0: {} require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} + ret@0.1.15: {} + ret@0.5.0: {} reusify@1.1.0: {} @@ -1343,6 +1756,15 @@ snapshots: set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + siginfo@2.0.0: {} sonic-boom@4.2.1: @@ -1432,3 +1854,7 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + xtend@4.0.2: {} + + yallist@4.0.0: {} diff --git a/src/app.ts b/src/app.ts index aae127a..534d06a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,40 +6,26 @@ import { connectionKinds, oauthModes, providers, - type AccessClaimPayload, type AppSlug, - type IssueClaimRequest, + type OAuthMode, type OAuthConnectionBinding, + 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 { type HeimdallConfig, loadConfig } from "./config.js"; +import { createOAuthRuntimeRegistry, type OAuthRuntimeRegistry } from "./oauth.js"; import { buildAuthorizationUrl, providerCatalog, providerExpectedEnv } from "./providers.js"; import { createRuntimeKeyMaterial, signJwt, verifyJwt } from "./signing.js"; +import { createStore, type HeimdallStore } from "./store/index.js"; +import { type CreateAccountInput, type StoredCapabilityGrant, type UpsertLinkedIdentityInput } from "./store/types.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(); + store?: HeimdallStore; + oauthRuntimes?: Partial; } function buildDiscovery(config: HeimdallConfig) { @@ -59,36 +45,37 @@ function buildDiscovery(config: HeimdallConfig) { }; } +function nowEpochSeconds(): number { + return Math.floor(Date.now() / 1000); +} + function buildOAuthStateToken(options: { config: HeimdallConfig; provider: Provider; appSlug: AppSlug; - mode: OAuthStartRequest["mode"]; + mode: OAuthMode; returnTo: string; connection: OAuthConnectionBinding | undefined; keys: ReturnType; }): { 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 - ); + const payload: OAuthStatePayload = { + 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, + }; + const token = signJwt(payload as unknown as Record, options.keys); return { token, @@ -96,11 +83,69 @@ function buildOAuthStateToken(options: { }; } +function parseOAuthStatePayload(payload: Record): OAuthStatePayload | null { + if ( + payload.typ !== "heimdall_oauth_state" || + typeof payload.provider !== "string" || + !providers.includes(payload.provider as Provider) || + typeof payload.app_slug !== "string" || + !appSlugs.includes(payload.app_slug as AppSlug) || + typeof payload.mode !== "string" || + !oauthModes.includes(payload.mode as OAuthMode) || + typeof payload.return_to !== "string" || + typeof payload.iss !== "string" || + typeof payload.sub !== "string" || + typeof payload.aud !== "string" || + typeof payload.jti !== "string" || + typeof payload.iat !== "number" || + typeof payload.nbf !== "number" || + typeof payload.exp !== "number" + ) { + return null; + } + + return payload as unknown as OAuthStatePayload; +} + +function buildGrantFacts(grants: StoredCapabilityGrant[]): string[] { + return grants.map((grant) => `grant.${grant.capability}`); +} + +function prefersHtml(acceptHeader: string | undefined): boolean { + if (!acceptHeader) { + return false; + } + + return acceptHeader.includes("text/html") || acceptHeader.includes("application/xhtml+xml"); +} + +function buildRedirectUrl(returnTo: string, params: Record): 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 { const config = options.config ?? loadConfig(); + const store = options.store ?? (await createStore(config)); const keys = createRuntimeKeyMaterial(config); + const oauthRuntimes: OAuthRuntimeRegistry = { + ...createOAuthRuntimeRegistry(), + ...(options.oauthRuntimes ?? {}), + }; const app = Fastify({ logger: false }); - app.decorate("heimdallContext", { config, keys }); + app.decorate("heimdallContext", { config, keys, store, oauthRuntimes }); + app.addHook("onClose", async () => { + await store.close(); + }); await app.register(cors, { origin: true, @@ -111,6 +156,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise( + app.post<{ Params: { appSlug: AppSlug }; Body: import("./contracts.js").IssueClaimRequest }>( "/v1/apps/:appSlug/claims/issue", { schema: { @@ -352,65 +584,14 @@ export async function buildApp(options: BuildAppOptions = {}): Promise { - 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 issued = await issueAccessClaim({ + config, + keys, + store, + input: mapIssueClaimRequest(request.params.appSlug, request.body), }); - - 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, 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 issued; } ); diff --git a/src/claims.ts b/src/claims.ts new file mode 100644 index 0000000..a6fcd9d --- /dev/null +++ b/src/claims.ts @@ -0,0 +1,174 @@ +import { randomUUID } from "node:crypto"; +import { + type AccessClaimPayload, + type AppSlug, + type IssueClaimRequest, + type LinkedIdentityInput, +} from "./contracts.js"; +import { getAppProfile } from "./app-profiles.js"; +import { type HeimdallConfig } from "./config.js"; +import { signJwt, type RuntimeKeyMaterial } from "./signing.js"; +import { type CreateSessionInput, type HeimdallStore } from "./store/types.js"; + +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(accountId: string, linkedIdentities: LinkedIdentityInput[], facts: string[]): string[] { + const normalized = new Set(facts); + + if (accountId.trim() || linkedIdentities.length) { + normalized.add("identity.authenticated"); + } + + return [...normalized].sort(); +} + +export interface IssueAccessClaimInput { + appSlug: AppSlug; + accountId: string; + displayName?: string; + linkedIdentities: LinkedIdentityInput[]; + facts: string[]; + sessionId?: string; + accessRevision?: number; + ttlSeconds?: number; +} + +export interface IssuedAccessClaimResult { + session: { + accountId: string; + sessionId: string; + appSlug: AppSlug; + accessRevision: number; + expiresAt: string; + }; + accessToken: string; + claimSet: AccessClaimPayload; + verification: { + issuer: string; + jwksUri: string; + alg: string; + kid: string; + }; + sharedCapabilities: string[]; + hybridCapabilities: ReturnType["capabilities"]; +} + +export async function issueAccessClaim(options: { + config: HeimdallConfig; + keys: RuntimeKeyMaterial; + store: HeimdallStore; + input: IssueAccessClaimInput; +}): Promise { + const profile = getAppProfile(options.input.appSlug); + const facts = normalizeFacts(options.input.accountId, options.input.linkedIdentities, options.input.facts); + const factSet = new Set(facts); + const sharedCapabilities = profile.evaluateSharedCapabilities({ + accountId: options.input.accountId, + facts: factSet, + identities: options.input.linkedIdentities, + }); + + const issuedAt = nowEpochSeconds(); + const ttlSeconds = clampTtlSeconds( + options.input.ttlSeconds ?? options.config.sessionTtlSeconds, + options.config.sessionTtlSeconds + ); + const expiresAtEpoch = issuedAt + ttlSeconds; + const sessionId = options.input.sessionId ?? randomUUID(); + const accessRevision = options.input.accessRevision ?? 1; + + const claimSet: AccessClaimPayload = { + iss: options.config.issuer, + aud: profile.slug, + sub: options.input.accountId, + sid: sessionId, + jti: randomUUID(), + iat: issuedAt, + nbf: issuedAt, + exp: expiresAtEpoch, + typ: "heimdall_access", + account_id: options.input.accountId, + access_revision: accessRevision, + app: { + slug: profile.slug, + profile_version: profile.profileVersion, + }, + facts, + capabilities: sharedCapabilities, + identities: options.input.linkedIdentities, + }; + + if (options.input.displayName) { + claimSet.display_name = options.input.displayName; + } + + const accessToken = signJwt(claimSet as unknown as Record, options.keys); + const sessionRecord: CreateSessionInput = { + id: sessionId, + accountId: options.input.accountId, + appSlug: profile.slug, + createdAt: new Date(issuedAt * 1000).toISOString(), + lastSeenAt: new Date(issuedAt * 1000).toISOString(), + expiresAt: new Date(expiresAtEpoch * 1000).toISOString(), + accessRevision, + claimsJson: claimSet as unknown as Record, + }; + await options.store.createSession(sessionRecord); + + return { + session: { + accountId: options.input.accountId, + sessionId, + appSlug: profile.slug, + accessRevision, + expiresAt: sessionRecord.expiresAt, + }, + accessToken, + claimSet, + verification: { + issuer: options.config.issuer, + jwksUri: `${options.config.publicBaseUrl}/.well-known/jwks.json`, + alg: options.keys.alg, + kid: options.keys.kid, + }, + sharedCapabilities, + hybridCapabilities: profile.capabilities.filter((capability) => capability.mode === "hybrid"), + }; +} + +export function mapIssueClaimRequest(appSlug: AppSlug, request: IssueClaimRequest): IssueAccessClaimInput { + const input: IssueAccessClaimInput = { + appSlug, + accountId: request.accountId, + linkedIdentities: request.linkedIdentities ?? [], + facts: request.facts ?? [], + }; + + if (request.displayName !== undefined) { + input.displayName = request.displayName; + } + + if (request.sessionId !== undefined) { + input.sessionId = request.sessionId; + } + + if (request.accessRevision !== undefined) { + input.accessRevision = request.accessRevision; + } + + if (request.ttlSeconds !== undefined) { + input.ttlSeconds = request.ttlSeconds; + } + + return input; +} diff --git a/src/config.ts b/src/config.ts index 6e011a0..ae84fd9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,6 +5,17 @@ export interface ProviderClientConfig { clientSecret?: string; } +export interface StorageConfig { + backend: "memory" | "postgres"; + databaseUrl?: string; + applySchemaOnStartup: boolean; +} + +export interface RepixelizerBindingConfig { + discordGuildId?: string; + discordAllowedRoleIds: string[]; +} + export interface HeimdallConfig { serviceName: string; host: string; @@ -15,6 +26,10 @@ export interface HeimdallConfig { stateTtlSeconds: number; signingPrivateKeyPem?: string; signingKeyId?: string; + storage: StorageConfig; + apps: { + repixelizer: RepixelizerBindingConfig; + }; providers: Record; } @@ -31,6 +46,34 @@ function readInt(envValue: string | undefined, fallback: number): number { return Number.isFinite(parsed) ? parsed : fallback; } +function readBoolean(envValue: string | undefined, fallback: boolean): boolean { + if (!envValue) { + return fallback; + } + + const normalized = envValue.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + + if (["0", "false", "no", "off"].includes(normalized)) { + return false; + } + + return fallback; +} + +function readCsv(envValue: string | undefined): string[] { + if (!envValue) { + return []; + } + + return envValue + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + function readProviderConfig(env: NodeJS.ProcessEnv, provider: Provider): ProviderClientConfig { const prefix = `GC_ACCESS_PROVIDER_${provider.toUpperCase()}`; const config: ProviderClientConfig = {}; @@ -53,6 +96,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig 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 storageBackend = + env.GC_ACCESS_STORAGE_BACKEND === "postgres" || env.GC_ACCESS_DATABASE_URL ? "postgres" : "memory"; const providersConfig = Object.fromEntries( providers.map((provider) => [provider, readProviderConfig(env, provider)]) ) as Record; @@ -65,6 +110,15 @@ 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), + storage: { + backend: storageBackend, + applySchemaOnStartup: readBoolean(env.GC_ACCESS_APPLY_SCHEMA_ON_STARTUP, true), + }, + apps: { + repixelizer: { + discordAllowedRoleIds: readCsv(env.GC_ACCESS_APP_REPIXELIZER_DISCORD_ALLOWED_ROLE_IDS), + }, + }, providers: providersConfig, }; @@ -77,5 +131,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): HeimdallConfig config.signingKeyId = env.GC_ACCESS_SIGNING_KEY_ID; } + if (env.GC_ACCESS_DATABASE_URL) { + config.storage.databaseUrl = env.GC_ACCESS_DATABASE_URL; + } + + if (env.GC_ACCESS_APP_REPIXELIZER_DISCORD_GUILD_ID) { + config.apps.repixelizer.discordGuildId = env.GC_ACCESS_APP_REPIXELIZER_DISCORD_GUILD_ID; + } + return config; } diff --git a/src/contracts.ts b/src/contracts.ts index 3652df1..d33a7c7 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -28,6 +28,22 @@ export interface OAuthStartRequest { requestedScopes?: string[]; } +export interface OAuthStatePayload { + iss: string; + sub: AppSlug; + aud: Provider; + jti: string; + iat: number; + nbf: number; + exp: number; + typ: "heimdall_oauth_state"; + provider: Provider; + app_slug: AppSlug; + mode: OAuthMode; + return_to: string; + connection: OAuthConnectionBinding | null; +} + export interface LinkedIdentityInput { provider: Provider; providerUserId: string; diff --git a/src/oauth.ts b/src/oauth.ts new file mode 100644 index 0000000..89affc5 --- /dev/null +++ b/src/oauth.ts @@ -0,0 +1,276 @@ +import { Buffer } from "node:buffer"; +import { type AppSlug, type OAuthConnectionBinding, type Provider } from "./contracts.js"; +import { type HeimdallConfig } from "./config.js"; +import { type CreateEntitlementSnapshotInput } from "./store/types.js"; + +export interface OAuthTokenSet { + accessToken: string; + refreshToken?: string; + tokenType: string; + scope: string[]; + expiresAt?: string; + raw: Record; +} + +export interface ResolvedIdentity { + provider: Provider; + providerUserId: string; + username?: string; + displayName?: string; + primaryEmail?: string; + avatarUrl?: string; + profile: Record; +} + +export interface EntitlementEvaluation { + facts: string[]; + snapshots: CreateEntitlementSnapshotInput[]; +} + +export interface OAuthCallbackContext { + appSlug: AppSlug; + accountId: string; + connection: OAuthConnectionBinding | null; +} + +export interface OAuthProviderRuntime { + exchangeAuthorizationCode(options: { + config: HeimdallConfig; + code: string; + redirectUri: string; + }): Promise; + resolveIdentity(options: { accessToken: string }): Promise; + evaluateEntitlements(options: { + config: HeimdallConfig; + callback: OAuthCallbackContext; + identity: ResolvedIdentity; + tokenSet: OAuthTokenSet; + }): Promise; +} + +function buildErrorMessage(status: number, body: string, fallback: string): string { + return `${fallback} (status ${status}): ${body || "empty response"}`; +} + +async function fetchJson(input: string | URL, init: RequestInit, fallbackError: string): Promise { + const response = await fetch(input, init); + const text = await response.text(); + + if (!response.ok) { + throw new Error(buildErrorMessage(response.status, text, fallbackError)); + } + + return (text ? JSON.parse(text) : {}) as T; +} + +async function exchangeDiscordAuthorizationCode(options: { + config: HeimdallConfig; + code: string; + redirectUri: string; +}): Promise { + const providerConfig = options.config.providers.discord; + if (!providerConfig.clientId || !providerConfig.clientSecret) { + throw new Error("Discord OAuth is not fully configured."); + } + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: options.code, + redirect_uri: options.redirectUri, + }); + const basicAuth = Buffer.from(`${providerConfig.clientId}:${providerConfig.clientSecret}`).toString("base64"); + const tokenResponse = await fetchJson<{ + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; + scope: string; + }>( + "https://discord.com/api/v10/oauth2/token", + { + method: "POST", + headers: { + Authorization: `Basic ${basicAuth}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }, + "Discord token exchange failed" + ); + + const tokenSet: OAuthTokenSet = { + accessToken: tokenResponse.access_token, + tokenType: tokenResponse.token_type, + scope: tokenResponse.scope.split(/\s+/).filter(Boolean), + expiresAt: new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString(), + raw: tokenResponse as unknown as Record, + }; + + if (tokenResponse.refresh_token) { + tokenSet.refreshToken = tokenResponse.refresh_token; + } + + return tokenSet; +} + +async function resolveDiscordIdentity(options: { accessToken: string }): Promise { + const user = await fetchJson<{ + id: string; + username?: string; + global_name?: string; + email?: string; + avatar?: string | null; + }>( + "https://discord.com/api/v10/users/@me", + { + headers: { + Authorization: `Bearer ${options.accessToken}`, + }, + }, + "Discord identity lookup failed" + ); + + const identity: ResolvedIdentity = { + provider: "discord", + providerUserId: user.id, + profile: user as unknown as Record, + }; + + if (user.username) { + identity.username = user.username; + } + + const displayName = user.global_name ?? user.username; + if (displayName) { + identity.displayName = displayName; + } + + if (user.email) { + identity.primaryEmail = user.email; + } + + if (user.avatar) { + identity.avatarUrl = `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`; + } + + return identity; +} + +async function evaluateDiscordEntitlements(options: { + config: HeimdallConfig; + callback: OAuthCallbackContext; + tokenSet: OAuthTokenSet; +}): Promise { + if (options.callback.appSlug !== "repixelizer") { + return { facts: [], snapshots: [] }; + } + + const guildId = options.config.apps.repixelizer.discordGuildId; + const allowedRoleIds = options.config.apps.repixelizer.discordAllowedRoleIds; + + if (!guildId) { + return { facts: [], snapshots: [] }; + } + + const response = await fetch( + `https://discord.com/api/v10/users/@me/guilds/${guildId}/member`, + { + headers: { + Authorization: `Bearer ${options.tokenSet.accessToken}`, + }, + } + ); + + if (response.status === 404) { + return { + facts: [], + snapshots: [ + { + accountId: options.callback.accountId, + provider: "discord", + scope: `repixelizer:discord_member:${guildId}`, + evaluatedAt: new Date().toISOString(), + isAllowed: false, + reasonCode: "not_in_guild", + reasonDetail: "Current user is not a member of the configured GameCult guild.", + rawSummaryJson: { guildId }, + }, + ], + }; + } + + const text = await response.text(); + if (!response.ok) { + throw new Error(buildErrorMessage(response.status, text, "Discord guild member lookup failed")); + } + + const member = (text ? JSON.parse(text) : {}) as { + nick?: string; + roles?: string[]; + joined_at?: string; + }; + const roles = member.roles ?? []; + const matchedRoles = allowedRoleIds.filter((roleId) => roles.includes(roleId)); + const facts = ["discord.in_guild"]; + + if (matchedRoles.length > 0) { + facts.push("discord.allowed_role"); + } + + return { + facts, + snapshots: [ + { + accountId: options.callback.accountId, + provider: "discord", + scope: `repixelizer:discord_role_access:${guildId}`, + evaluatedAt: new Date().toISOString(), + isAllowed: matchedRoles.length > 0, + reasonCode: matchedRoles.length > 0 ? "matched_role" : "missing_role", + reasonDetail: + matchedRoles.length > 0 + ? `Matched Discord roles: ${matchedRoles.join(", ")}` + : allowedRoleIds.length + ? "User is in the guild but lacks the configured Repixelizer access role." + : "No Repixelizer Discord role ids are configured.", + rawSummaryJson: { + guildId, + roles, + matchedRoles, + nick: member.nick, + joinedAt: member.joined_at, + }, + }, + ], + }; +} + +const discordRuntime: OAuthProviderRuntime = { + exchangeAuthorizationCode: exchangeDiscordAuthorizationCode, + resolveIdentity: resolveDiscordIdentity, + evaluateEntitlements: evaluateDiscordEntitlements, +}; + +const notImplementedRuntime: OAuthProviderRuntime = { + async exchangeAuthorizationCode() { + throw new Error("Provider callback exchange is not implemented yet."); + }, + async resolveIdentity() { + throw new Error("Provider identity resolution is not implemented yet."); + }, + async evaluateEntitlements() { + return { facts: [], snapshots: [] }; + }, +}; + +export type OAuthRuntimeRegistry = Record; + +export function createOAuthRuntimeRegistry(): OAuthRuntimeRegistry { + return { + discord: discordRuntime, + patreon: notImplementedRuntime, + github: notImplementedRuntime, + twitch: notImplementedRuntime, + youtube: notImplementedRuntime, + }; +} diff --git a/src/providers.ts b/src/providers.ts index 8ebec69..e15bcb5 100644 --- a/src/providers.ts +++ b/src/providers.ts @@ -18,7 +18,7 @@ export const providerCatalog: Record = { key: "discord", displayName: "Discord", authorizationEndpoint: "https://discord.com/oauth2/authorize", - defaultScopes: ["identify", "guilds"], + defaultScopes: ["identify", "email", "guilds.members.read"], roles: ["identity", "entitlement"], clientIdEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_ID", clientSecretEnv: "GC_ACCESS_PROVIDER_DISCORD_CLIENT_SECRET", diff --git a/src/store/in-memory.ts b/src/store/in-memory.ts new file mode 100644 index 0000000..85251c0 --- /dev/null +++ b/src/store/in-memory.ts @@ -0,0 +1,210 @@ +import { randomUUID } from "node:crypto"; +import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js"; +import { + type CreateAccountInput, + type CreateAuditEventInput, + type CreateCapabilityGrantInput, + type CreateEntitlementSnapshotInput, + type CreateSessionInput, + type HeimdallStore, + type StoredAccount, + type StoredCapabilityGrant, + type StoredLinkedIdentity, + type StoredSession, + type UpsertLinkedIdentityInput, +} from "./types.js"; + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function isGrantActive(grant: StoredCapabilityGrant, appSlug: AppSlug, at: string): boolean { + if (grant.status !== "active") { + return false; + } + + if (grant.expiresAt && grant.expiresAt <= at) { + return false; + } + + return grant.scopeType === "global" || grant.scopeId === appSlug; +} + +export class InMemoryStore implements HeimdallStore { + private readonly accounts = new Map(); + private readonly linkedIdentities = new Map(); + private readonly grants = new Map(); + private readonly sessions = new Map(); + private readonly entitlementSnapshots = new Map(); + private readonly auditEvents = new Map(); + + async ensureSchema(): Promise { + return; + } + + async close(): Promise { + return; + } + + async createAccount(input: CreateAccountInput): Promise { + const account: StoredAccount = { + id: input.id ?? randomUUID(), + createdAt: input.createdAt, + lastSeenAt: input.lastSeenAt, + }; + + if (input.displayName !== undefined) { + account.displayName = input.displayName; + } + + if (input.primaryEmail !== undefined) { + account.primaryEmail = input.primaryEmail; + } + + this.accounts.set(account.id, clone(account)); + return clone(account); + } + + async touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise { + const account = this.accounts.get(accountId); + if (!account) { + throw new Error(`Account '${accountId}' was not found.`); + } + + account.lastSeenAt = at; + if (updates?.displayName) { + account.displayName = updates.displayName; + } + if (updates?.primaryEmail) { + account.primaryEmail = updates.primaryEmail; + } + } + + async findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise { + for (const linkedIdentity of this.linkedIdentities.values()) { + if (linkedIdentity.provider === provider && linkedIdentity.providerUserId === providerUserId) { + return clone(this.accounts.get(linkedIdentity.accountId) ?? null); + } + } + + return null; + } + + async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise { + const existing = [...this.linkedIdentities.values()].find( + (identity) => identity.provider === input.provider && identity.providerUserId === input.providerUserId + ); + const id = existing?.id ?? input.id ?? randomUUID(); + const linkedIdentity: StoredLinkedIdentity = { + id, + accountId: input.accountId, + provider: input.provider, + providerUserId: input.providerUserId, + scopes: [...input.scopes], + profileJson: clone(input.profileJson), + createdAt: existing?.createdAt ?? input.createdAt, + updatedAt: input.updatedAt, + }; + + if (input.username !== undefined) { + linkedIdentity.username = input.username; + } + + if (input.displayName !== undefined) { + linkedIdentity.displayName = input.displayName; + } + + if (input.primaryEmail !== undefined) { + linkedIdentity.primaryEmail = input.primaryEmail; + } + + if (input.accessTokenEncrypted !== undefined) { + linkedIdentity.accessTokenEncrypted = input.accessTokenEncrypted; + } + + if (input.refreshTokenEncrypted !== undefined) { + linkedIdentity.refreshTokenEncrypted = input.refreshTokenEncrypted; + } + + if (input.tokenExpiresAt !== undefined) { + linkedIdentity.tokenExpiresAt = input.tokenExpiresAt; + } + + this.linkedIdentities.set(id, clone(linkedIdentity)); + return clone(linkedIdentity); + } + + async listLinkedIdentitiesForAccount(accountId: string): Promise { + return [...this.linkedIdentities.values()] + .filter((identity) => identity.accountId === accountId) + .map((identity) => { + const linkedIdentity: LinkedIdentityInput = { + provider: identity.provider, + providerUserId: identity.providerUserId, + }; + + if (identity.username !== undefined) { + linkedIdentity.username = identity.username; + } + + if (identity.displayName !== undefined) { + linkedIdentity.displayName = identity.displayName; + } + + return linkedIdentity; + }); + } + + async createCapabilityGrant(input: CreateCapabilityGrantInput): Promise { + const grant: StoredCapabilityGrant = { + id: input.id ?? randomUUID(), + accountId: input.accountId, + scopeType: input.scopeType, + capability: input.capability, + source: input.source, + status: input.status, + createdAt: input.createdAt, + updatedAt: input.updatedAt, + }; + + if (input.scopeId !== undefined) { + grant.scopeId = input.scopeId; + } + + if (input.expiresAt !== undefined) { + grant.expiresAt = input.expiresAt; + } + + if (input.note !== undefined) { + grant.note = input.note; + } + + this.grants.set(grant.id, clone(grant)); + return clone(grant); + } + + async listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise { + return [...this.grants.values()] + .filter((grant) => grant.accountId === accountId && isGrantActive(grant, appSlug, at)) + .map((grant) => clone(grant)); + } + + async createSession(input: CreateSessionInput): Promise { + const session: StoredSession = clone(input); + this.sessions.set(session.id, session); + return clone(session); + } + + async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise { + const key = `${input.accountId}:${input.provider}:${input.scope}`; + this.entitlementSnapshots.set(key, clone(input)); + } + + async createAuditEvent(input: CreateAuditEventInput): Promise { + const event: CreateAuditEventInput = clone({ + ...input, + id: input.id ?? randomUUID(), + }); + this.auditEvents.set(event.id ?? randomUUID(), event); + } +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..7617594 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,21 @@ +import { type HeimdallConfig } from "../config.js"; +import { InMemoryStore } from "./in-memory.js"; +import { createPostgresStore } from "./postgres.js"; +import { type HeimdallStore } from "./types.js"; + +export * from "./types.js"; +export { InMemoryStore } from "./in-memory.js"; +export { PostgresStore, createPostgresStore } from "./postgres.js"; + +export async function createStore(config: HeimdallConfig): Promise { + const store = + config.storage.backend === "postgres" + ? createPostgresStore(config.storage.databaseUrl ?? "postgres://127.0.0.1/heimdall") + : new InMemoryStore(); + + if (config.storage.applySchemaOnStartup) { + await store.ensureSchema(); + } + + return store; +} diff --git a/src/store/postgres.ts b/src/store/postgres.ts new file mode 100644 index 0000000..ff851e1 --- /dev/null +++ b/src/store/postgres.ts @@ -0,0 +1,455 @@ +import { randomUUID } from "node:crypto"; +import { Pool, type PoolClient, type QueryResultRow } from "pg"; +import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js"; +import { CREATE_SCHEMA_SQL } from "./schema.js"; +import { + type CreateAccountInput, + type CreateAuditEventInput, + type CreateCapabilityGrantInput, + type CreateEntitlementSnapshotInput, + type CreateSessionInput, + type HeimdallStore, + type StoredAccount, + type StoredCapabilityGrant, + type StoredLinkedIdentity, + type StoredSession, + type UpsertLinkedIdentityInput, +} from "./types.js"; + +interface AccountRow extends QueryResultRow { + id: string; + created_at: string; + last_seen_at: string; + display_name: string | null; + primary_email: string | null; +} + +interface LinkedIdentityRow extends QueryResultRow { + id: string; + account_id: string; + provider: Provider; + provider_user_id: string; + username: string | null; + display_name: string | null; + primary_email: string | null; + access_token_encrypted: string | null; + refresh_token_encrypted: string | null; + token_expires_at: string | null; + scopes: string; + profile_json: Record; + created_at: string; + updated_at: string; +} + +interface GrantRow extends QueryResultRow { + id: string; + account_id: string; + scope_type: "global" | "app"; + scope_id: AppSlug | null; + capability: string; + source: string; + status: "active" | "revoked"; + expires_at: string | null; + note: string | null; + created_at: string; + updated_at: string; +} + +interface SessionRow extends QueryResultRow { + id: string; + account_id: string; + app_slug: AppSlug; + created_at: string; + last_seen_at: string; + expires_at: string; + access_revision: number; + claims_json: Record; +} + +function expectRow(row: T | undefined, label: string): T { + if (!row) { + throw new Error(`${label} query returned no rows.`); + } + + return row; +} + +function nullable(value: T | undefined): T | null { + return value ?? null; +} + +function parseScopes(value: string): string[] { + return value.split(" ").map((item) => item.trim()).filter(Boolean); +} + +function mapAccountRow(row: AccountRow): StoredAccount { + const account: StoredAccount = { + id: row.id, + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + }; + + if (row.display_name) { + account.displayName = row.display_name; + } + + if (row.primary_email) { + account.primaryEmail = row.primary_email; + } + + return account; +} + +function mapLinkedIdentityRow(row: LinkedIdentityRow): StoredLinkedIdentity { + const identity: StoredLinkedIdentity = { + id: row.id, + accountId: row.account_id, + provider: row.provider, + providerUserId: row.provider_user_id, + scopes: parseScopes(row.scopes), + profileJson: row.profile_json, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + + if (row.username) { + identity.username = row.username; + } + + if (row.display_name) { + identity.displayName = row.display_name; + } + + if (row.primary_email) { + identity.primaryEmail = row.primary_email; + } + + if (row.access_token_encrypted) { + identity.accessTokenEncrypted = row.access_token_encrypted; + } + + if (row.refresh_token_encrypted) { + identity.refreshTokenEncrypted = row.refresh_token_encrypted; + } + + if (row.token_expires_at) { + identity.tokenExpiresAt = row.token_expires_at; + } + + return identity; +} + +function mapGrantRow(row: GrantRow): StoredCapabilityGrant { + const grant: StoredCapabilityGrant = { + id: row.id, + accountId: row.account_id, + scopeType: row.scope_type, + capability: row.capability, + source: row.source, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + + if (row.scope_id) { + grant.scopeId = row.scope_id; + } + + if (row.expires_at) { + grant.expiresAt = row.expires_at; + } + + if (row.note) { + grant.note = row.note; + } + + return grant; +} + +function mapSessionRow(row: SessionRow): StoredSession { + return { + id: row.id, + accountId: row.account_id, + appSlug: row.app_slug, + createdAt: row.created_at, + lastSeenAt: row.last_seen_at, + expiresAt: row.expires_at, + accessRevision: row.access_revision, + claimsJson: row.claims_json, + }; +} + +export class PostgresStore implements HeimdallStore { + constructor(private readonly pool: Pick) {} + + async ensureSchema(): Promise { + await this.pool.query(CREATE_SCHEMA_SQL); + } + + async close(): Promise { + await this.pool.end(); + } + + async createAccount(input: CreateAccountInput): Promise { + const id = input.id ?? randomUUID(); + const result = await this.pool.query( + ` + INSERT INTO accounts (id, created_at, last_seen_at, display_name, primary_email) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, + [id, input.createdAt, input.lastSeenAt, nullable(input.displayName), nullable(input.primaryEmail)] + ); + + return mapAccountRow(expectRow(result.rows[0], "createAccount")); + } + + async touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise { + await this.pool.query( + ` + UPDATE accounts + SET last_seen_at = $2, + display_name = COALESCE($3, display_name), + primary_email = COALESCE($4, primary_email) + WHERE id = $1 + `, + [accountId, at, nullable(updates?.displayName), nullable(updates?.primaryEmail)] + ); + } + + async findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise { + const result = await this.pool.query( + ` + SELECT accounts.* + FROM accounts + INNER JOIN linked_identities ON linked_identities.account_id = accounts.id + WHERE linked_identities.provider = $1 + AND linked_identities.provider_user_id = $2 + LIMIT 1 + `, + [provider, providerUserId] + ); + + return result.rowCount ? mapAccountRow(expectRow(result.rows[0], "findAccountByLinkedIdentity")) : null; + } + + async upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise { + const id = input.id ?? randomUUID(); + const result = await this.pool.query( + ` + INSERT INTO linked_identities ( + id, + account_id, + provider, + provider_user_id, + username, + display_name, + primary_email, + access_token_encrypted, + refresh_token_encrypted, + token_expires_at, + scopes, + profile_json, + created_at, + updated_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14 + ) + ON CONFLICT (provider, provider_user_id) + DO UPDATE SET + account_id = EXCLUDED.account_id, + username = EXCLUDED.username, + display_name = EXCLUDED.display_name, + primary_email = EXCLUDED.primary_email, + access_token_encrypted = EXCLUDED.access_token_encrypted, + refresh_token_encrypted = EXCLUDED.refresh_token_encrypted, + token_expires_at = EXCLUDED.token_expires_at, + scopes = EXCLUDED.scopes, + profile_json = EXCLUDED.profile_json, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + id, + input.accountId, + input.provider, + input.providerUserId, + nullable(input.username), + nullable(input.displayName), + nullable(input.primaryEmail), + nullable(input.accessTokenEncrypted), + nullable(input.refreshTokenEncrypted), + nullable(input.tokenExpiresAt), + input.scopes.join(" "), + JSON.stringify(input.profileJson), + input.createdAt, + input.updatedAt, + ] + ); + + return mapLinkedIdentityRow(expectRow(result.rows[0], "upsertLinkedIdentity")); + } + + async listLinkedIdentitiesForAccount(accountId: string): Promise { + const result = await this.pool.query( + ` + SELECT * + FROM linked_identities + WHERE account_id = $1 + ORDER BY created_at ASC + `, + [accountId] + ); + + return result.rows.map((row) => { + const linkedIdentity: LinkedIdentityInput = { + provider: row.provider, + providerUserId: row.provider_user_id, + }; + + if (row.username) { + linkedIdentity.username = row.username; + } + + if (row.display_name) { + linkedIdentity.displayName = row.display_name; + } + + return linkedIdentity; + }); + } + + async createCapabilityGrant(input: CreateCapabilityGrantInput): Promise { + const id = input.id ?? randomUUID(); + const result = await this.pool.query( + ` + INSERT INTO capability_grants ( + id, account_id, scope_type, scope_id, capability, + source, status, expires_at, note, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `, + [ + id, + input.accountId, + input.scopeType, + nullable(input.scopeId), + input.capability, + input.source, + input.status, + nullable(input.expiresAt), + nullable(input.note), + input.createdAt, + input.updatedAt, + ] + ); + + return mapGrantRow(expectRow(result.rows[0], "createCapabilityGrant")); + } + + async listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise { + const result = await this.pool.query( + ` + SELECT * + FROM capability_grants + WHERE account_id = $1 + AND status = 'active' + AND (expires_at IS NULL OR expires_at > $3) + AND ( + scope_type = 'global' + OR (scope_type = 'app' AND scope_id = $2) + ) + ORDER BY created_at ASC + `, + [accountId, appSlug, at] + ); + + return result.rows.map(mapGrantRow); + } + + async createSession(input: CreateSessionInput): Promise { + const result = await this.pool.query( + ` + INSERT INTO sessions ( + id, account_id, app_slug, created_at, last_seen_at, + expires_at, claims_json, access_revision + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `, + [ + input.id, + input.accountId, + input.appSlug, + input.createdAt, + input.lastSeenAt, + input.expiresAt, + JSON.stringify(input.claimsJson), + input.accessRevision, + ] + ); + + return mapSessionRow(expectRow(result.rows[0], "createSession")); + } + + async upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise { + await this.pool.query( + ` + INSERT INTO entitlement_snapshots ( + id, account_id, provider, scope, evaluated_at, + is_allowed, reason_code, reason_detail, raw_summary_json + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (account_id, provider, scope) + DO UPDATE SET + evaluated_at = EXCLUDED.evaluated_at, + is_allowed = EXCLUDED.is_allowed, + reason_code = EXCLUDED.reason_code, + reason_detail = EXCLUDED.reason_detail, + raw_summary_json = EXCLUDED.raw_summary_json + `, + [ + randomUUID(), + input.accountId, + input.provider, + input.scope, + input.evaluatedAt, + input.isAllowed, + input.reasonCode, + nullable(input.reasonDetail), + JSON.stringify(input.rawSummaryJson), + ] + ); + } + + async createAuditEvent(input: CreateAuditEventInput): Promise { + await this.pool.query( + ` + INSERT INTO audit_events ( + id, account_id, session_id, app_slug, event_type, + event_payload_json, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, + [ + input.id ?? randomUUID(), + nullable(input.accountId), + nullable(input.sessionId), + nullable(input.appSlug), + input.eventType, + JSON.stringify(input.eventPayloadJson), + input.createdAt, + ] + ); + } +} + +export function createPostgresStore(databaseUrl: string): HeimdallStore { + const pool = new Pool({ + connectionString: databaseUrl, + }); + return new PostgresStore(pool); +} diff --git a/src/store/schema.ts b/src/store/schema.ts new file mode 100644 index 0000000..5f06481 --- /dev/null +++ b/src/store/schema.ts @@ -0,0 +1,87 @@ +export const CREATE_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS accounts ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + display_name TEXT, + primary_email TEXT +); + +CREATE TABLE IF NOT EXISTS linked_identities ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + username TEXT, + display_name TEXT, + primary_email TEXT, + access_token_encrypted TEXT, + refresh_token_encrypted TEXT, + token_expires_at TIMESTAMPTZ, + scopes TEXT NOT NULL DEFAULT '', + profile_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(provider, provider_user_id) +); + +CREATE INDEX IF NOT EXISTS linked_identities_account_id_idx + ON linked_identities(account_id); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + app_slug TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + claims_json JSONB NOT NULL, + access_revision INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS sessions_account_id_idx + ON sessions(account_id); + +CREATE TABLE IF NOT EXISTS capability_grants ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + scope_type TEXT NOT NULL, + scope_id TEXT, + capability TEXT NOT NULL, + source TEXT NOT NULL, + status TEXT NOT NULL, + expires_at TIMESTAMPTZ, + note TEXT, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS capability_grants_lookup_idx + ON capability_grants(account_id, scope_type, scope_id, status); + +CREATE TABLE IF NOT EXISTS entitlement_snapshots ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + scope TEXT NOT NULL, + evaluated_at TIMESTAMPTZ NOT NULL, + is_allowed BOOLEAN NOT NULL, + reason_code TEXT NOT NULL, + reason_detail TEXT, + raw_summary_json JSONB NOT NULL, + UNIQUE(account_id, provider, scope) +); + +CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + account_id TEXT, + session_id TEXT, + app_slug TEXT, + event_type TEXT NOT NULL, + event_payload_json JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS audit_events_lookup_idx + ON audit_events(account_id, session_id, app_slug, created_at); +`; diff --git a/src/store/types.ts b/src/store/types.ts new file mode 100644 index 0000000..9e25997 --- /dev/null +++ b/src/store/types.ts @@ -0,0 +1,137 @@ +import { type AppSlug, type LinkedIdentityInput, type Provider } from "../contracts.js"; + +export interface StoredAccount { + id: string; + createdAt: string; + lastSeenAt: string; + displayName?: string; + primaryEmail?: string; +} + +export interface CreateAccountInput { + id?: string; + createdAt: string; + lastSeenAt: string; + displayName?: string; + primaryEmail?: string; +} + +export interface StoredLinkedIdentity { + id: string; + accountId: string; + provider: Provider; + providerUserId: string; + username?: string; + displayName?: string; + primaryEmail?: string; + accessTokenEncrypted?: string; + refreshTokenEncrypted?: string; + tokenExpiresAt?: string; + scopes: string[]; + profileJson: Record; + createdAt: string; + updatedAt: string; +} + +export interface UpsertLinkedIdentityInput { + id?: string; + accountId: string; + provider: Provider; + providerUserId: string; + username?: string; + displayName?: string; + primaryEmail?: string; + accessTokenEncrypted?: string; + refreshTokenEncrypted?: string; + tokenExpiresAt?: string; + scopes: string[]; + profileJson: Record; + createdAt: string; + updatedAt: string; +} + +export interface StoredSession { + id: string; + accountId: string; + appSlug: AppSlug; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + accessRevision: number; + claimsJson: Record; +} + +export interface CreateSessionInput { + id: string; + accountId: string; + appSlug: AppSlug; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + accessRevision: number; + claimsJson: Record; +} + +export interface StoredCapabilityGrant { + id: string; + accountId: string; + scopeType: "global" | "app"; + scopeId?: AppSlug; + capability: string; + source: string; + status: "active" | "revoked"; + expiresAt?: string; + note?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateCapabilityGrantInput { + id?: string; + accountId: string; + scopeType: "global" | "app"; + scopeId?: AppSlug; + capability: string; + source: string; + status: "active" | "revoked"; + expiresAt?: string; + note?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateEntitlementSnapshotInput { + accountId: string; + provider: Provider; + scope: string; + evaluatedAt: string; + isAllowed: boolean; + reasonCode: string; + reasonDetail?: string; + rawSummaryJson: Record; +} + +export interface CreateAuditEventInput { + id?: string; + accountId?: string; + sessionId?: string; + appSlug?: AppSlug; + eventType: string; + eventPayloadJson: Record; + createdAt: string; +} + +export interface HeimdallStore { + ensureSchema(): Promise; + close(): Promise; + createAccount(input: CreateAccountInput): Promise; + touchAccount(accountId: string, at: string, updates?: { displayName?: string; primaryEmail?: string }): Promise; + findAccountByLinkedIdentity(provider: Provider, providerUserId: string): Promise; + upsertLinkedIdentity(input: UpsertLinkedIdentityInput): Promise; + listLinkedIdentitiesForAccount(accountId: string): Promise; + createCapabilityGrant(input: CreateCapabilityGrantInput): Promise; + listActiveGrants(accountId: string, appSlug: AppSlug, at: string): Promise; + createSession(input: CreateSessionInput): Promise; + upsertEntitlementSnapshot(input: CreateEntitlementSnapshotInput): Promise; + createAuditEvent(input: CreateAuditEventInput): Promise; +} diff --git a/state/evidence.jsonl b/state/evidence.jsonl index d929d57..a2848ef 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -4,3 +4,4 @@ {"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."} +{"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."} diff --git a/state/map.yaml b/state/map.yaml index 54514df..f3927b4 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -1,7 +1,7 @@ project: name: Heimdall status: active - phase: service_contract_skeleton + phase: first_provider_persistence premise: > Build Heimdall as one reusable GameCult auth authority service for OAuth, linked identities, local sessions, grants, and capability claims without @@ -12,8 +12,9 @@ objective: Create a shared auth authority service that Repixelizer, StreamPixels, and future GameCult-hosted experiments can consume. current: > - Lock the Heimdall service boundary, Yggdrasil deployment shape, and app - integration rules before implementation starts breeding myths. + Use the new stateful provider slice to land the first consumer verifier + seam and harden key/token custody before app integrations start breeding + fresh sludge. constraints: - Heimdall owns auth/control-plane authority; app-domain data stays app-owned by default. @@ -59,7 +60,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/service-contract.md` records the concrete HTTP/JWKS contract of the landed service slice. - `docs/app-profiles/*.md` are thin app bindings, not duplicate brains. - 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. @@ -68,12 +69,15 @@ accepted_design: - 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. + - The first stateful slice adds durable auth/control-plane storage plus a real Discord callback path for Repixelizer. 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. + - Durable auth persistence landed for accounts, linked identities, sessions, grants, entitlement snapshots, and audit events. + - The first real provider slice landed for Discord callback exchange, identity resolution, entitlement evaluation, and session/claim issuance for Repixelizer. rejected_paths: - name: per_app_auth_copy_paste @@ -84,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 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. + 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. diff --git a/tests/app.test.ts b/tests/app.test.ts index 3d80fc5..6a92ad3 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -2,7 +2,8 @@ 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"; +import { type OAuthProviderRuntime } from "../src/oauth.js"; +import { verifyJwt } from "../src/signing.js"; function createTestConfig(): HeimdallConfig { return { @@ -13,6 +14,16 @@ function createTestConfig(): HeimdallConfig { issuer: "https://heimdall.gamecult.org", sessionTtlSeconds: 3600, stateTtlSeconds: 600, + storage: { + backend: "memory", + applySchemaOnStartup: true, + }, + apps: { + repixelizer: { + discordGuildId: "gamecult-guild", + discordAllowedRoleIds: ["role-repixelizer"], + }, + }, providers: { discord: { clientId: "discord-client", clientSecret: "discord-secret" }, patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" }, @@ -23,6 +34,51 @@ function createTestConfig(): HeimdallConfig { }; } +function createMockDiscordRuntime(): OAuthProviderRuntime { + return { + async exchangeAuthorizationCode() { + return { + accessToken: "discord-access-token", + refreshToken: "discord-refresh-token", + tokenType: "Bearer", + scope: ["identify", "email", "guilds.members.read"], + expiresAt: "2026-04-26T13:00:00.000Z", + raw: { source: "test" }, + }; + }, + async resolveIdentity() { + return { + provider: "discord", + providerUserId: "discord-user-123", + username: "meta", + displayName: "Meta", + primaryEmail: "meta@gamecult.org", + profile: { id: "discord-user-123", username: "meta" }, + }; + }, + async evaluateEntitlements({ callback }) { + return { + facts: ["discord.in_guild", "discord.allowed_role"], + snapshots: [ + { + accountId: callback.accountId, + provider: "discord", + scope: "repixelizer:discord_role_access:gamecult-guild", + evaluatedAt: "2026-04-26T12:00:00.000Z", + isAllowed: true, + reasonCode: "matched_role", + reasonDetail: "Matched Repixelizer access role.", + rawSummaryJson: { + guildId: "gamecult-guild", + matchedRoles: ["role-repixelizer"], + }, + }, + ], + }; + }, + }; +} + const apps: Awaited>[] = []; function getPublicKey(app: FastifyInstance) { @@ -37,13 +93,28 @@ function getPublicKey(app: FastifyInstance) { ).heimdallContext.keys.publicKey; } +async function startDiscordSignIn(app: FastifyInstance): Promise { + 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); + return response.json().stateToken as string; +} + afterEach(async () => { while (apps.length) { await apps.pop()?.close(); } }); -describe("Heimdall service skeleton", () => { +describe("Heimdall service", () => { it("serves discovery and JWKS metadata", async () => { const app = await buildApp({ config: createTestConfig() }); apps.push(app); @@ -96,50 +167,84 @@ describe("Heimdall service skeleton", () => { 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", - }) - ); + const verified = verifyJwt(payload.stateToken as string, getPublicKey(app)); + expect(verified.valid).toBe(true); + if (verified.valid) { + expect(verified.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() }); + it("completes a provider callback and issues a verified Repixelizer claim", async () => { + const app = await buildApp({ + config: createTestConfig(), + oauthRuntimes: { + discord: createMockDiscordRuntime(), + }, + }); apps.push(app); + const stateToken = await startDiscordSignIn(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", - }, - ], - }, + method: "GET", + url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`, }); expect(response.statusCode).toBe(201); const payload = response.json(); - expect(payload.sharedCapabilities).toEqual( - expect.arrayContaining(["app_access", "queue_submit", "admin_access"]) + expect(payload.sharedCapabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"])); + expect(payload.account).toEqual( + expect.objectContaining({ + displayName: "Meta", + primaryEmail: "meta@gamecult.org", + }) ); + expect(payload.entitlements.facts).toEqual(expect.arrayContaining(["discord.allowed_role"])); - const verified = verifyJwt(payload.accessToken, getPublicKey(app)); + const verified = verifyJwt(payload.accessToken as string, getPublicKey(app)); expect(verified.valid).toBe(true); + if (verified.valid) { + expect(verified.payload).toEqual( + expect.objectContaining({ + typ: "heimdall_access", + aud: "repixelizer", + account_id: payload.account.id, + }) + ); + expect(verified.payload.capabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"])); + } + }); - const tokenDecoded = decodeJwt(payload.accessToken); - expect(tokenDecoded.payload.capabilities).toEqual( - expect.arrayContaining(["app_access", "queue_submit", "admin_access"]) - ); + 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 () => { diff --git a/tests/store.test.ts b/tests/store.test.ts new file mode 100644 index 0000000..41089a7 --- /dev/null +++ b/tests/store.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { newDb } from "pg-mem"; +import { PostgresStore } from "../src/store/postgres.js"; + +describe("PostgresStore", () => { + it("round-trips core auth records through postgres", async () => { + const db = newDb(); + const adapter = db.adapters.createPg(); + const pool = new adapter.Pool(); + const store = new PostgresStore(pool); + await store.ensureSchema(); + + const now = "2026-04-26T12:00:00.000Z"; + const account = await store.createAccount({ + createdAt: now, + lastSeenAt: now, + displayName: "Meta", + primaryEmail: "meta@gamecult.org", + }); + + await store.upsertLinkedIdentity({ + accountId: account.id, + provider: "discord", + providerUserId: "discord-user-123", + username: "meta", + displayName: "Meta", + primaryEmail: "meta@gamecult.org", + accessTokenEncrypted: "encrypted-access-token", + refreshTokenEncrypted: "encrypted-refresh-token", + tokenExpiresAt: "2026-04-26T13:00:00.000Z", + scopes: ["identify", "email"], + profileJson: { id: "discord-user-123", username: "meta" }, + createdAt: now, + updatedAt: now, + }); + + const found = await store.findAccountByLinkedIdentity("discord", "discord-user-123"); + expect(found).toEqual( + expect.objectContaining({ + id: account.id, + displayName: "Meta", + primaryEmail: "meta@gamecult.org", + }) + ); + + const linkedIdentities = await store.listLinkedIdentitiesForAccount(account.id); + expect(linkedIdentities).toEqual([ + { + provider: "discord", + providerUserId: "discord-user-123", + username: "meta", + displayName: "Meta", + }, + ]); + + await store.createCapabilityGrant({ + accountId: account.id, + scopeType: "app", + scopeId: "repixelizer", + capability: "app_access", + source: "manual", + status: "active", + note: "Test grant", + createdAt: now, + updatedAt: now, + }); + + const grants = await store.listActiveGrants(account.id, "repixelizer", "2026-04-26T12:01:00.000Z"); + expect(grants).toEqual([ + expect.objectContaining({ + capability: "app_access", + scopeType: "app", + scopeId: "repixelizer", + }), + ]); + + await store.createSession({ + id: "session-123", + accountId: account.id, + appSlug: "repixelizer", + createdAt: now, + lastSeenAt: now, + expiresAt: "2026-04-26T13:00:00.000Z", + accessRevision: 1, + claimsJson: { + iss: "https://heimdall.gamecult.org", + aud: "repixelizer", + }, + }); + + await store.upsertEntitlementSnapshot({ + accountId: account.id, + provider: "discord", + scope: "repixelizer:discord_role_access:gamecult-guild", + evaluatedAt: now, + isAllowed: true, + reasonCode: "matched_role", + reasonDetail: "Matched test role", + rawSummaryJson: { matchedRoles: ["role-repixelizer"] }, + }); + + await store.createAuditEvent({ + accountId: account.id, + sessionId: "session-123", + appSlug: "repixelizer", + eventType: "oauth_callback_succeeded", + eventPayloadJson: { provider: "discord" }, + createdAt: now, + }); + + await store.close(); + }); +});