Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6a704e5
feat: add durable attestation ledger schema
miyaontherelay Aug 8, 2026
a4c6789
feat: add atomic attestation ledger storage
miyaontherelay Aug 8, 2026
1e3b811
feat: add attestation grant and finalize routes
miyaontherelay Aug 8, 2026
e888eb4
feat: authorize attestation grant scope
miyaontherelay Aug 8, 2026
96966de
fix: isolate grant api key authentication
miyaontherelay Aug 8, 2026
67d5c24
test: cover ledger chain isolation
miyaontherelay Aug 8, 2026
84f2800
test: avoid duplicate cross-org fixture hash
miyaontherelay Aug 8, 2026
c854730
fix: scope ledger hashes by organization
miyaontherelay Aug 8, 2026
7b7ebed
test: require operator key for late grants
miyaontherelay Aug 8, 2026
7112374
feat: bind identity sponsors with OIDC proofs
Aug 8, 2026
748856e
docs: expose sponsor proof flow in SDKs
Aug 8, 2026
a98ba8c
fix: anchor OIDC sponsor chains to verified subjects
Aug 8, 2026
16cc14d
fix: retain exact OIDC evidence claims
Aug 8, 2026
4063809
test: keep sponsor binding immutable
Aug 8, 2026
ef131bb
test: expose OIDC evidence on identity events
Aug 8, 2026
d60e746
fix: harden OIDC sponsor evidence
Aug 8, 2026
56c4138
test: fail closed on invalid OIDC config
Aug 8, 2026
3247e98
perf: share OIDC discovery cache across app instances
Aug 8, 2026
231b9cd
fix: reject OIDC token confusion
Aug 8, 2026
61b9acc
feat: commit OIDC sponsor evidence atomically
Aug 8, 2026
3754a50
feat: bind sponsor proofs to intent
Aug 8, 2026
bcbee95
fix: harden identity and attestation contracts
Aug 8, 2026
528b8d9
fix: throttle sponsor proof verification
Aug 8, 2026
512f049
fix: answer review on ledger integrity and JWKS rotation
khaliqgant Aug 8, 2026
6580d92
docs: align the published contract with the scope grammar and intent …
khaliqgant Aug 8, 2026
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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,35 @@ RELAYAUTH_SIGNING_KEY_PEM_PUBLIC="$(cat public.pem)" \
npm run start
```

### OIDC sponsor binding

Organizations can require identity sponsors to be established by an OIDC
provider. Organizations omitted from `RELAYAUTH_SPONSOR_FEDERATIONS` remain in
the backward-compatible `legacy` mode.

```bash
RELAYAUTH_SPONSOR_FEDERATIONS='{
"org_example": {
"sponsorBinding": "oidc",
"issuer": "https://id.example.com",
"clientId": "relayauth-registration",
"allowedAudiences": ["relayauth-registration"],
"sponsorIdClaim": "sub"
}
}'
```

An authenticated caller exchanges a fresh IdP token at
`POST /v1/sponsors/proof` with
`{ "idToken": "...", "intent": "identity.create" }`. The returned short-lived,
intent-bound `sponsorProof` names the verified human principal. A proof whose
intent is `identity.create` must accompany `sponsorId` on
`POST /v1/identities`; proofs issued for other purposes, such as `approval`,
cannot be substituted. RelayAuth checks the issuer, audience, lifetime, RS256
signature, intent, and matching sponsor before creating the identity. The
identity response records whether its sponsor was established in `legacy` or
`oidc` mode.

## Scope Format

Scopes follow `plane:resource:action:path`:
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/scope-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ACTIONS = [
"send",
"invoke",
"trigger",
"grant",
Comment thread
khaliqgant marked this conversation as resolved.
"*",
] as const;
const IDENTIFIER_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/python/relayauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
RelayAuthTokenClaims,
Role,
ScopeTemplate,
SponsorProof,
TokenBudget,
TokenPair,
)
Expand All @@ -49,6 +50,7 @@
"Role",
"ScopeChecker",
"ScopeTemplate",
"SponsorProof",
"TokenBudget",
"TokenExpiredError",
"TokenPair",
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/python/relayauth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
CreateIdentityInput,
RelayAuthTokenClaims,
Role,
SponsorProof,
TokenPair,
)

Expand Down Expand Up @@ -164,6 +165,14 @@ async def create_identity(
data = await self._request("/v1/identities", method="POST", body=payload)
return AgentIdentity.from_dict(data)

async def create_sponsor_proof(self, id_token: str, intent: str) -> SponsorProof:
data = await self._request(
"/v1/sponsors/proof",
method="POST",
body={"idToken": id_token, "intent": intent},
)
return SponsorProof.from_dict(data)

async def get_identity(self, identity_id: str) -> AgentIdentity:
data = await self._request(f"/v1/identities/{quote(identity_id, safe='')}")
return AgentIdentity.from_dict(data)
Expand Down
26 changes: 26 additions & 0 deletions packages/sdk/python/relayauth/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class AgentIdentity:
metadata: dict[str, str]
createdAt: str
updatedAt: str
sponsorBinding: dict[str, Any] | None = None

@classmethod
def from_dict(cls, value: Any) -> AgentIdentity:
Expand All @@ -155,6 +156,9 @@ def from_dict(cls, value: Any) -> AgentIdentity:
metadata=_string_map(data.get("metadata")),
createdAt=str(data["createdAt"]),
updatedAt=str(data["updatedAt"]),
sponsorBinding=dict(data["sponsorBinding"])
if isinstance(data.get("sponsorBinding"), dict)
else None,
)


Expand All @@ -166,6 +170,8 @@ class CreateIdentityInput:
roles: list[str] = field(default_factory=list)
metadata: dict[str, str] = field(default_factory=dict)
workspaceId: str | None = None
sponsorId: str | None = None
sponsorProof: str | None = None

@classmethod
def from_dict(cls, value: Any) -> CreateIdentityInput:
Expand All @@ -177,6 +183,26 @@ def from_dict(cls, value: Any) -> CreateIdentityInput:
roles=_string_list(data.get("roles")),
metadata=_string_map(data.get("metadata")),
workspaceId=_optional_string(data.get("workspaceId")),
sponsorId=_optional_string(data.get("sponsorId")),
sponsorProof=_optional_string(data.get("sponsorProof")),
)


@dataclass(slots=True)
class SponsorProof:
sponsorId: str
sponsorProof: str
expiresAt: str
intent: str

@classmethod
def from_dict(cls, value: Any) -> SponsorProof:
data = dict(value)
return cls(
sponsorId=str(data["sponsorId"]),
sponsorProof=str(data["sponsorProof"]),
expiresAt=str(data["expiresAt"]),
intent=str(data["intent"]),
)


Expand Down
26 changes: 26 additions & 0 deletions packages/sdk/python/tests/test_relayauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ def identity_payload() -> dict[str, Any]:
"metadata": {"team": "ops"},
"createdAt": "2026-03-25T10:00:00.000Z",
"updatedAt": "2026-03-25T10:00:00.000Z",
"sponsorBinding": {"mode": "legacy"},
}


Expand Down Expand Up @@ -394,6 +395,31 @@ async def test_client_create_identity(identity_payload: dict[str, Any]) -> None:
assert json.loads(request.content.decode("utf-8")) == {"orgId": "org_123", **payload}


@pytest.mark.asyncio
@respx.mock
async def test_client_create_sponsor_proof() -> None:
payload = {
"sponsorId": "user_alice",
"sponsorProof": "signed-proof",
"expiresAt": "2026-03-25T10:05:00.000Z",
"intent": "identity.create",
}
route = respx.post(f"{BASE_URL}/v1/sponsors/proof").mock(
return_value=httpx_response(201, payload),
)
client = _create_client(base_url=BASE_URL, token=AUTH_TOKEN)

proof = await client.create_sponsor_proof("fixture-id-token", "identity.create")

assert _to_mapping(proof) == payload
request = route.calls.last.request
assert request.headers["authorization"] == f"Bearer {AUTH_TOKEN}"
assert json.loads(request.content.decode("utf-8")) == {
"idToken": "fixture-id-token",
"intent": "identity.create",
}


@pytest.mark.asyncio
@respx.mock
async def test_client_get_identity(identity_payload: dict[str, Any]) -> None:
Expand Down
27 changes: 27 additions & 0 deletions packages/sdk/typescript/src/__tests__/client-identities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,33 @@ test("createIdentity posts to /v1/identities with orgId in the JSON body", async
});
});

test("createSponsorProof posts an OIDC id token to /v1/sponsors/proof", async (t) => {
const client = createClient();
const response = {
sponsorId: "user_alice",
sponsorProof: "signed-proof",
expiresAt: "2026-03-25T10:05:00.000Z",
intent: "identity.create",
};
const fetchMock = mockFetch(() => jsonResponse(response, 201));
t.after(() => fetchMock.restore());

const proof = await client.createSponsorProof({
idToken: "fixture-id-token",
intent: "identity.create",
});

assert.deepEqual(proof, response);
const request = await inspectCall(fetchMock.calls[0]);
assert.equal(request.url.toString(), `${baseUrl}/v1/sponsors/proof`);
assert.equal(request.method, "POST");
assertBearer(request.headers);
assert.deepEqual(JSON.parse(request.body), {
idToken: "fixture-id-token",
intent: "identity.create",
});
});

test("getIdentity fetches /v1/identities/:id with bearer auth", async (t) => {
const client = createClient();
const fetchMock = mockFetch(() => jsonResponse(identity));
Expand Down
9 changes: 9 additions & 0 deletions packages/sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import type {
AuditEntry,
AuditQuery,
CreateIdentityInput,
CreateSponsorProofInput,
IdentityStatus,
PathTokenPair,
PathTokenIssueRequest,
RelayAuthTokenClaims,
Role,
SponsorProof,
TokenPair,
WorkspacePathTokenIssueRequest,
WorkspacePathTokenPair,
Expand Down Expand Up @@ -115,6 +117,13 @@ export class RelayAuthClient {
});
}

async createSponsorProof(input: CreateSponsorProofInput): Promise<SponsorProof> {
return this._request<SponsorProof>("/v1/sponsors/proof", {
method: "POST",
body: input,
});
}

async getIdentity(identityId: string): Promise<AgentIdentity> {
return this._request<AgentIdentity>(
`/v1/identities/${encodeURIComponent(identityId)}`,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/typescript/src/scope-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ACTIONS = [
"send",
"invoke",
"trigger",
"grant",
"*",
] as const;
const IDENTIFIER_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
Expand Down
Loading
Loading