Delete claims/issue: zero callers, arbitrary-facts oracle bypassing entitlement evaluation
R1: no GC service calls this route. Its shape (caller-supplied facts handed straight to issueAccessClaim) is a second entitlement authority even when gated, so authenticating it would preserve the oracle behind a password. Deletion-only commit: route, discovery advertisement, mapper, contract type, positive test, and doc mentions in architecture.md/service-contract.md/ fresh-workspace-handoff.md.
This commit is contained in:
parent
8e6bc83ce6
commit
a999d48b6d
7 changed files with 10 additions and 194 deletions
|
|
@ -470,7 +470,6 @@ Everything else is internal by the design already written down:
|
|||
|---|---|
|
||||
| `/.well-known/heimdall-configuration` | GC apps, mesh-reachable |
|
||||
| `/v1/apps` | GC apps, mesh-reachable |
|
||||
| `/v1/apps/{appSlug}/claims/issue` | GC app backends, mesh-reachable |
|
||||
| `/healthz` | local probes |
|
||||
|
||||
The private command plane is not part of this question. It is
|
||||
|
|
|
|||
|
|
@ -65,7 +65,6 @@ Current shape includes:
|
|||
- `configurationUri`
|
||||
- `oauthStartEndpoint`
|
||||
- `oauthCallbackEndpoint`
|
||||
- `claimIssueEndpoint`
|
||||
- `supportedProviders`
|
||||
- `apps`
|
||||
|
||||
|
|
@ -497,63 +496,6 @@ Current helper contract:
|
|||
|
||||
This is a reference seam, not a magical shared middleware product yet.
|
||||
|
||||
### `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": ["entitlement.app_access", "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-05-26T12:00:00.000Z"
|
||||
},
|
||||
"accessToken": "<signed-jwt>",
|
||||
"refreshToken": "<signed-refresh-jwt>",
|
||||
"refresh": {
|
||||
"expiresAt": "2026-05-26T12:00:00.000Z"
|
||||
},
|
||||
"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:
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ Do not trust this file for the exact live HEAD. Always check git.
|
|||
- `/v1/oauth/{provider}/start`
|
||||
- `/v1/oauth/{provider}/callback`
|
||||
- `/v1/apps/{appSlug}/auth-completions/redeem`
|
||||
- `/v1/apps/{appSlug}/claims/issue`
|
||||
- `/v1/apps/{appSlug}/sessions/refresh`
|
||||
- `/v1/apps/{appSlug}/managed-credentials/resolve`
|
||||
- the Discord callback path now performs real code exchange, identity
|
||||
|
|
|
|||
60
src/app.ts
60
src/app.ts
|
|
@ -21,7 +21,7 @@ import {
|
|||
type OAuthStartRequest,
|
||||
type Provider,
|
||||
} from "./contracts.js";
|
||||
import { mapIssueClaimRequest, issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
|
||||
import { issueAccessClaim, type IssueAccessClaimInput } from "./claims.js";
|
||||
import { builtInAppProfiles, getAppProfile, serializeAppProfile, supportsProvider } from "./app-profiles.js";
|
||||
import {
|
||||
AppRegistrationError,
|
||||
|
|
@ -79,7 +79,6 @@ function buildDiscovery(config: HeimdallConfig) {
|
|||
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,
|
||||
|
|
@ -1333,63 +1332,6 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: import("./contracts.js").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 issued = await issueAccessClaim({
|
||||
config,
|
||||
keys,
|
||||
store,
|
||||
input: mapIssueClaimRequest(request.params.appSlug, request.body),
|
||||
});
|
||||
reply.code(201);
|
||||
return issued;
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { appSlug: AppSlug }; Body: RefreshSessionRequest }>(
|
||||
"/v1/apps/:appSlug/sessions/refresh",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
|||
import {
|
||||
type AccessClaimPayload,
|
||||
type AppSlug,
|
||||
type IssueClaimRequest,
|
||||
type LinkedIdentityInput,
|
||||
type RefreshTokenPayload,
|
||||
} from "./contracts.js";
|
||||
|
|
@ -181,30 +180,3 @@ export async function issueAccessClaim(options: {
|
|||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,16 +103,6 @@ export interface LinkedIdentityInput {
|
|||
displayName?: string;
|
||||
}
|
||||
|
||||
export interface IssueClaimRequest {
|
||||
accountId: string;
|
||||
sessionId?: string;
|
||||
displayName?: string;
|
||||
facts?: string[];
|
||||
linkedIdentities?: LinkedIdentityInput[];
|
||||
accessRevision?: number;
|
||||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
export interface RedeemAuthCompletionRequest {
|
||||
completionCode: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ 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 { entitlementFacts, grantFacts, identityFacts } from "../src/facts.js";
|
||||
import { entitlementFacts, identityFacts } from "../src/facts.js";
|
||||
import { createOAuthRuntimeRegistry, type OAuthProviderRuntime } from "../src/oauth.js";
|
||||
import { verifyJwt } from "../src/signing.js";
|
||||
import { createHeimdallAccessTokenVerifier } from "../src/verifier.js";
|
||||
import { InMemoryStore } from "../src/store/index.js";
|
||||
|
||||
function createTestConfig(): HeimdallConfig {
|
||||
|
|
@ -834,51 +833,24 @@ describe("Heimdall service", () => {
|
|||
expect(verified.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("issues direct claims from provider-agnostic entitlement facts", async () => {
|
||||
it("no longer exposes a route to mint claims from caller-supplied facts", 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: [entitlementFacts.appAccess, grantFacts.operator],
|
||||
linkedIdentities: [
|
||||
{
|
||||
provider: "discord",
|
||||
providerUserId: "123456789",
|
||||
username: "meta",
|
||||
},
|
||||
],
|
||||
},
|
||||
payload: { accountId: "probe" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const payload = response.json();
|
||||
expect(payload.sharedCapabilities).toEqual(
|
||||
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
|
||||
);
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(app.printRoutes()).not.toContain("claims/issue");
|
||||
|
||||
const verifier = createHeimdallAccessTokenVerifier({
|
||||
issuer: "https://heimdall.gamecult.org",
|
||||
appSlug: "repixelizer",
|
||||
jwks: {
|
||||
keys: [getHeimdallContext(app).keys.publicJwk],
|
||||
},
|
||||
const discoveryResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/.well-known/heimdall-configuration",
|
||||
});
|
||||
expect(verifier.verify(payload.accessToken as string)).toEqual(
|
||||
expect.objectContaining({
|
||||
valid: true,
|
||||
claimSet: expect.objectContaining({
|
||||
aud: "repixelizer",
|
||||
app: expect.objectContaining({
|
||||
slug: "repixelizer",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(discoveryResponse.json()).not.toHaveProperty("claimIssueEndpoint");
|
||||
});
|
||||
|
||||
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue