Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/space-declaration-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@getcirrus/oauth-provider": patch
---

Fix `space:` scope resolution in the consent UI. `@atcute/lexicon-resolver` validates fetched lexicon documents against `@atcute/lexicon-doc`, whose def-type whitelist has no `type: "space"`, so a correctly published space type declaration failed with `InvalidLexiconSchemaError` and the consent screen showed "could not resolve space type declaration" for every space scope. `resolveSpaceDeclaration` now replicates the resolver's authenticated steps itself — resolve the DID document, fetch the record proof CAR, and verify the commit signature — then validates `defs.main` against the local space shape, keeping full proof verification while tolerating the unknown def type. The workaround can be removed once upstream `@atcute/lexicon-doc` gains the `space` def type.
4 changes: 4 additions & 0 deletions packages/oauth-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm"
},
"dependencies": {
"@atcute/crypto": "^2.3.0",
"@atcute/identity": "^1.1.0",
"@atcute/identity-resolver": "^1.1.3",
"@atcute/lexicon-resolver": "^0.1.6",
"@atcute/lexicons": "^1.2.6",
"@atcute/repo": "^0.1.1",
"@atproto/oauth-scopes": "0.0.0-spaces-alpha-20260818163953",
"@atproto/oauth-types": "^0.6.3",
"@atproto/syntax": "^0.4.2",
"jose": "^6.1.3"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
"@atproto/crypto": "^0.4.5",
"@atproto/repo": "^0.8.12",
"@cloudflare/workers-types": "^4.20251225.0",
"publint": "^0.3.16",
"tsdown": "^0.18.3",
Expand Down
135 changes: 131 additions & 4 deletions packages/oauth-provider/src/permission-sets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,30 @@
* `@atcute/lexicon-resolver` (DNS-based authority + AT-URI schema fetch).
*/

import {
getPublicKeyFromDidController,
P256PublicKey,
Secp256k1PublicKey,
type PublicKey,
} from "@atcute/crypto";
import {
getAtprotoVerificationMaterial,
getPdsEndpoint,
} from "@atcute/identity";
import type { DidDocumentResolver } from "@atcute/identity-resolver";
import {
DohJsonLexiconAuthorityResolver,
LexiconSchemaResolver,
} from "@atcute/lexicon-resolver";
import type { DidDocumentResolver } from "@atcute/identity-resolver";
import type { Nsid } from "@atcute/lexicons/syntax";
import { isNsid, type AtprotoDid, type Nsid } from "@atcute/lexicons/syntax";
import { verifyRecord } from "@atcute/repo";
import type { LexiconPermissionSet } from "@atproto/oauth-scopes";

export type { LexiconPermissionSet };

/** Collection that lexicon schema records (permission sets, spaces) live in. */
const LEXICON_SCHEMA_COLLECTION = "com.atproto.lexicon.schema";

/**
* A lexicon space type declaration (`type: 'space'`), per the permissioned
* data proposal. Mirrors `@atproto/oauth-scopes`'s internal `LexiconSpace`
Expand Down Expand Up @@ -80,6 +94,7 @@ export interface CreateAtcutePermissionSetResolverOptions {
export function createAtcutePermissionSetResolver(
opts: CreateAtcutePermissionSetResolverOptions,
): PermissionSetResolver {
const fetchImpl = opts.fetch ?? globalThis.fetch;
const authority = new DohJsonLexiconAuthorityResolver({
dohUrl: opts.dohUrl,
fetch: opts.fetch,
Expand All @@ -96,15 +111,127 @@ export function createAtcutePermissionSetResolver(
?.main as { type?: string } | undefined;
};

/**
* Resolve a lexicon record's `defs.main` with the SAME proof guarantees as
* {@link LexiconSchemaResolver.resolve} — resolve the DID document, find the
* PDS, fetch the record as a proof CAR, and verify the commit signature
* against the key in the DID document — but WITHOUT its final
* whole-document validation.
*
* WORKAROUND: `@atcute/lexicon-resolver` finishes by parsing the fetched
* document with `@atcute/lexicon-doc`, whose def-type whitelist has no
* `type: "space"` (as of lexicon-doc 2.2.0). A correctly published
* spaces-alpha type declaration therefore throws `InvalidLexiconSchemaError`
* (invalid_literal at `.defs.main.type`) and never returns, so the consent
* UI shows "could not resolve space type declaration" for every space
* scope. Replicating steps 1–3 here keeps full proof verification on the
* space path while letting us validate `defs.main` against our own
* {@link LexiconSpace} shape instead.
*
* Deliberately NOT an unauthenticated `com.atproto.repo.getRecord`: the
* signature proof is part of the lexicon-resolution contract.
*
* Remove this and route the space path back through
* `LexiconSchemaResolver.resolve()` once lexicon-doc gains the `space` def
* type (upstream: https://github.com/mary-ext/atcute).
*/
const resolveVerifiedMain = async (
did: AtprotoDid,
nsid: Nsid,
): Promise<unknown> => {
// Step 1: DID document → PDS service endpoint.
const didDocument = await opts.didDocumentResolver.resolve(did);
const pdsEndpoint = getPdsEndpoint(didDocument);
if (!pdsEndpoint) {
throw new Error(`no atproto PDS in DID document; did=${did}`);
}

// Step 2: fetch the lexicon record as a proof CAR.
const url = new URL("/xrpc/com.atproto.sync.getRecord", pdsEndpoint);
url.searchParams.set("did", did);
url.searchParams.set("collection", LEXICON_SCHEMA_COLLECTION);
url.searchParams.set("rkey", nsid);
const response = await fetchImpl(url, {
headers: { accept: "application/vnd.ipld.car" },
});
if (!response.ok) {
throw new Error(
`failed to fetch lexicon record; nsid=${nsid}; status=${response.status}`,
);
}
const carBytes = new Uint8Array(await response.arrayBuffer());

// Step 3: verify the record's commit signature against the DID
// document's atproto key — identical to LexiconSchemaResolver's proof
// check, so this path is no weaker than the permission-set path.
const material = getAtprotoVerificationMaterial(didDocument);
if (!material) {
throw new Error(
`DID document has no atproto verification material; did=${did}`,
);
}
const found = getPublicKeyFromDidController(material);
const publicKey: PublicKey =
found.type === "p256"
? await P256PublicKey.importRaw(found.publicKeyBytes)
: await Secp256k1PublicKey.importRaw(found.publicKeyBytes);
const verified = await verifyRecord({
did,
collection: LEXICON_SCHEMA_COLLECTION,
rkey: nsid,
publicKey,
carBytes,
});

// Sanity-check the record envelope (mirrors the resolver) before
// trusting `defs.main`.
const raw = verified.record;
if (
typeof raw !== "object" ||
raw === null ||
(raw as { $type?: unknown }).$type !== LEXICON_SCHEMA_COLLECTION ||
(raw as { id?: unknown }).id !== nsid
) {
throw new Error(`invalid lexicon schema record; nsid=${nsid}`);
}
return (raw as { defs?: Record<string, unknown> }).defs?.main;
};

return {
async resolve(nsid) {
const main = await resolveMain(nsid);
if (!main || main.type !== "permission-set") return null;
return main as unknown as LexiconPermissionSet;
},
async resolveSpaceDeclaration(nsid) {
const main = await resolveMain(nsid);
if (!main || main.type !== "space") return null;
const did = await authority.resolve(nsid);
const main = await resolveVerifiedMain(did, nsid);
if (
typeof main !== "object" ||
main === null ||
(main as { type?: unknown }).type !== "space"
) {
// Not a space type declaration (e.g. a permission set). The
// caller treats null as "no space metadata", not an error.
return null;
}
// Validate against the local LexiconSpace shape. The record itself
// is already proof-verified above; this only guards the fields the
// consent UI and grant-time collection defaulting rely on.
const decl = main as Record<string, unknown>;
if (typeof decl.name !== "string") {
throw new Error(
`space type declaration is missing a name; nsid=${nsid}`,
);
}
if (
!Array.isArray(decl.collections) ||
!decl.collections.every((c) => isNsid(c))
) {
throw new Error(
`space type declaration has invalid collections; nsid=${nsid}`,
);
}
return main as unknown as LexiconSpace;
},
};
Expand Down
Loading
Loading