Skip to content

Commit 48b7a04

Browse files
authored
Merge pull request decentralized-identity#82 from decentralized-identity/feat/oauth-authorization-server-adapter
feat(oauth): authorization-server adapter seam with generic-OIDC reference adapter
2 parents 8ab1008 + 29fdcfc commit 48b7a04

20 files changed

Lines changed: 1282 additions & 0 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,25 @@ All notable changes to @kya-os/mcp will be documented here.
55
Format: https://keepachangelog.com/en/1.0.0/
66
Versioning: https://semver.org/spec/v2.0.0.html
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- `./authz` authorization seam: a neutral, method-agnostic
13+
`AuthorizationServerAdapter` port with a shared dispatch predicate, an
14+
`AuthorizationServerRegistry` that routes a tool's protection to one adapter,
15+
and a generic-OIDC reference adapter under `authz/oidc/` (mandatory S256 PKCE,
16+
RFC 8707 resource binding, injectable fetch seam — no named vendor IdP, per
17+
the donation's vendor-neutrality). The `AuthorizationRequirement` union
18+
(`oauth`/`mdl`/`idv`/`credential`/`none`) anticipates further adapters as
19+
siblings of `oidc/`.
20+
- `AccountabilityContext` projection (agent → accountable-admin → user →
21+
intent) that feeds the policy principal's `responsibleParty`; `orgRootDid` is
22+
a forward-compatible slot pending the organization root identity.
23+
- A deterministic, network-free in-memory OIDC example exercising the full
24+
authorization path. Additive; no new runtime dependency (zod, jose, Web
25+
Crypto only).
26+
827
## [1.5.0] - 2026-06-01
928

1029
Exposes the policy-request projection as a reusable primitive and adds a

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
"types": "./dist/policy/index.d.ts",
4848
"default": "./dist/policy/index.js"
4949
},
50+
"./authz": {
51+
"types": "./dist/authz/index.d.ts",
52+
"default": "./dist/authz/index.js"
53+
},
5054
"./schemas/*.json": "./schemas/*.json",
5155
"./package.json": "./package.json"
5256
},
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { projectAccountability, accountabilityToPolicyPrincipal } from '../accountability.js';
3+
import type { DelegationCredentialSubject } from '../../types/protocol.js';
4+
5+
/**
6+
* AccountabilityContext is the agent -> accountable-admin -> user -> intent
7+
* chain. It is a read-only projection from a delegation credential subject and
8+
* feeds PolicyRequest.principal, so policy can gate on accountability. The
9+
* admin rung is sourced from the credential `controller` until a dedicated
10+
* org-admin field lands; `orgRootDid` is a forward-compatible slot that stays
11+
* undefined until the Org Root DID work exists.
12+
*/
13+
14+
function subject(over: Partial<DelegationCredentialSubject['delegation']> = {}): DelegationCredentialSubject {
15+
return {
16+
id: 'did:key:zAgent',
17+
delegation: {
18+
id: 'del-1',
19+
issuerDid: 'did:web:org.example',
20+
subjectDid: 'did:key:zAgent',
21+
userDid: 'did:web:user.example',
22+
controller: 'did:web:org.example:admins:alice',
23+
scopes: ['vault:read'],
24+
constraints: {},
25+
status: 'active',
26+
...over,
27+
} as DelegationCredentialSubject['delegation'],
28+
};
29+
}
30+
31+
describe('projectAccountability', () => {
32+
it('projects agent, user, intent scopes, and the admin from controller', () => {
33+
const ctx = projectAccountability(subject());
34+
expect(ctx.agentDid).toBe('did:key:zAgent');
35+
expect(ctx.userDid).toBe('did:web:user.example');
36+
expect(ctx.accountableAdminDid).toBe('did:web:org.example:admins:alice');
37+
expect(ctx.scopes).toEqual(['vault:read']);
38+
});
39+
40+
it('leaves orgRootDid undefined (Org Root DID not yet landed)', () => {
41+
expect(projectAccountability(subject()).orgRootDid).toBeUndefined();
42+
});
43+
44+
it('omits accountableAdminDid when no controller is present', () => {
45+
const ctx = projectAccountability(subject({ controller: undefined }));
46+
expect(ctx.accountableAdminDid).toBeUndefined();
47+
});
48+
49+
it('defaults scopes to an empty array when none are delegated', () => {
50+
expect(projectAccountability(subject({ scopes: undefined })).scopes).toEqual([]);
51+
});
52+
});
53+
54+
describe('accountabilityToPolicyPrincipal', () => {
55+
it('maps the accountable admin onto PolicyRequest principal.responsibleParty', () => {
56+
const principal = accountabilityToPolicyPrincipal(projectAccountability(subject()));
57+
expect(principal.agentDid).toBe('did:key:zAgent');
58+
expect(principal.responsibleParty).toBe('did:web:org.example:admins:alice');
59+
});
60+
61+
it('omits responsibleParty when there is no accountable admin', () => {
62+
const principal = accountabilityToPolicyPrincipal(
63+
projectAccountability(subject({ controller: undefined })),
64+
);
65+
expect(principal.agentDid).toBe('did:key:zAgent');
66+
expect('responsibleParty' in principal).toBe(false);
67+
});
68+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
requirementMatchesAdapter,
4+
type AuthorizationServerAdapter,
5+
} from '../adapter.js';
6+
import type { ToolProtection } from '../requirement.js';
7+
8+
/**
9+
* AuthorizationServerAdapter is the neutral, one-responsibility port: given a
10+
* tool's protection, decide whether this adapter must run, produce a
11+
* challenge, and consume the result. `requirementMatchesAdapter` is the shared
12+
* dispatch predicate every adapter's `isRequired` is built on — exercised here
13+
* as real runtime code (the interface alone is erased at runtime).
14+
*/
15+
16+
const protect = (toolName: string, requirement: ToolProtection['requirement']): ToolProtection => ({
17+
toolName,
18+
requirement,
19+
});
20+
21+
describe('requirementMatchesAdapter', () => {
22+
it('matches when the protection requirement type equals the adapter type', () => {
23+
expect(
24+
requirementMatchesAdapter('oauth', protect('vault.read', { type: 'oauth', provider: 'generic-oidc' })),
25+
).toBe(true);
26+
});
27+
28+
it('does not match an unprotected tool (type none)', () => {
29+
expect(requirementMatchesAdapter('oauth', protect('ping', { type: 'none' }))).toBe(false);
30+
});
31+
32+
it('does not match when another adapter type owns the requirement', () => {
33+
expect(requirementMatchesAdapter('oauth', protect('verify.id', { type: 'idv' }))).toBe(false);
34+
});
35+
});
36+
37+
describe('AuthorizationServerAdapter (contract)', () => {
38+
it('a conforming adapter wires isRequired to the shared predicate', () => {
39+
const adapter: AuthorizationServerAdapter = {
40+
type: 'oauth',
41+
isRequired: (protection) => requirementMatchesAdapter('oauth', protection),
42+
async initiateFlow() {
43+
throw new Error('not exercised in this test');
44+
},
45+
async verifyAuthorization() {
46+
throw new Error('not exercised in this test');
47+
},
48+
};
49+
expect(adapter.type).toBe('oauth');
50+
expect(adapter.isRequired(protect('vault.read', { type: 'oauth', provider: 'generic-oidc' }))).toBe(true);
51+
expect(adapter.isRequired(protect('ping', { type: 'none' }))).toBe(false);
52+
});
53+
});
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { runInMemoryAuthorizationFlow } from '../examples/inmemory-oidc.js';
3+
4+
/**
5+
* End-to-end showcase of the authorization seam with zero network: a registry
6+
* holding the generic-OIDC reference adapter wired to an in-memory token
7+
* endpoint. Deterministic and CI-safe — it is the reliable demonstration that
8+
* the pluggable adapter pattern works edge to edge (protect a tool -> resolve
9+
* an adapter -> challenge -> verify -> delegation outcome -> policy principal).
10+
*/
11+
describe('in-memory OIDC authorization flow', () => {
12+
it('routes a protected tool through challenge, verification, and a policy principal', async () => {
13+
const outcome = await runInMemoryAuthorizationFlow({
14+
toolName: 'vault.read',
15+
requiredScopes: ['vault:read'],
16+
agentDid: 'did:key:zAgent',
17+
accountableAdminDid: 'did:web:org.example:admins:alice',
18+
});
19+
20+
// A challenge was produced for the protected tool.
21+
expect(outcome.challenge.error).toBe('needs_authorization');
22+
expect(new URL(outcome.challenge.authorizationUrl).searchParams.get('code_challenge_method')).toBe('S256');
23+
24+
// Verification succeeded through the in-memory token endpoint (no network).
25+
expect(outcome.result.valid).toBe(true);
26+
expect(outcome.result.credential?.agent_did).toBe('did:key:zAgent');
27+
expect(outcome.result.credential?.scopes).toContain('vault:read');
28+
29+
// The accountability chain reached the policy principal.
30+
expect(outcome.policyPrincipal.agentDid).toBe('did:key:zAgent');
31+
expect(outcome.policyPrincipal.responsibleParty).toBe('did:web:org.example:admins:alice');
32+
33+
// No external network was used.
34+
expect(outcome.networkCalls).toBe(0);
35+
});
36+
37+
it('produces a stable, deterministic result across runs', async () => {
38+
const a = await runInMemoryAuthorizationFlow({
39+
toolName: 'vault.read',
40+
requiredScopes: ['vault:read'],
41+
agentDid: 'did:key:zAgent',
42+
});
43+
const b = await runInMemoryAuthorizationFlow({
44+
toolName: 'vault.read',
45+
requiredScopes: ['vault:read'],
46+
agentDid: 'did:key:zAgent',
47+
});
48+
expect(a.result.credential?.scopes).toEqual(b.result.credential?.scopes);
49+
expect(a.result.valid).toBe(b.result.valid);
50+
});
51+
});

src/authz/__tests__/index.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, it, expect } from 'vitest';
2+
import * as oauth from '../index.js';
3+
4+
/**
5+
* The ./oauth barrel is the package's public authorization surface. This test
6+
* pins the exported value identifiers so an accidental removal is caught.
7+
*/
8+
describe('@kya-os/mcp/authz public surface', () => {
9+
it('exports the seam, registry, reference adapter, and helpers', () => {
10+
expect(typeof oauth.AuthorizationServerRegistry).toBe('function');
11+
expect(typeof oauth.GenericOidcAdapter).toBe('function');
12+
expect(typeof oauth.requirementMatchesAdapter).toBe('function');
13+
expect(typeof oauth.buildAuthorizeUrl).toBe('function');
14+
expect(typeof oauth.verifyS256Challenge).toBe('function');
15+
expect(typeof oauth.buildAuthorizationServerMetadata).toBe('function');
16+
expect(typeof oauth.projectAccountability).toBe('function');
17+
expect(typeof oauth.AuthorizationRequirementSchema).toBe('object');
18+
});
19+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { AuthorizationServerRegistry } from '../registry.js';
3+
import { GenericOidcAdapter } from '../oidc/oidc-adapter.js';
4+
import type { AuthorizationServerAdapter } from '../adapter.js';
5+
6+
/**
7+
* The registry routes a tool's protection to exactly one adapter by type, with
8+
* a sealed lifecycle — mirroring the provider-registry pattern so the
9+
* authorization seam composes the same pluggable way as the policy seam.
10+
*/
11+
12+
const oidc = () =>
13+
new GenericOidcAdapter({
14+
type: 'oauth',
15+
issuer: 'https://idp.example',
16+
authorizationEndpoint: 'https://idp.example/authorize',
17+
tokenEndpoint: 'https://idp.example/token',
18+
clientId: 'agent-client',
19+
scopes: ['openid'],
20+
});
21+
22+
describe('AuthorizationServerRegistry', () => {
23+
it('registers and resolves an adapter by type', () => {
24+
const registry = new AuthorizationServerRegistry();
25+
registry.register(oidc());
26+
expect(registry.get('oauth')?.type).toBe('oauth');
27+
expect(registry.list().map((a) => a.type)).toEqual(['oauth']);
28+
});
29+
30+
it('returns undefined for an unregistered type', () => {
31+
expect(new AuthorizationServerRegistry().get('idv')).toBeUndefined();
32+
});
33+
34+
it('rejects registering two adapters for the same type', () => {
35+
const registry = new AuthorizationServerRegistry();
36+
registry.register(oidc());
37+
expect(() => registry.register(oidc())).toThrow(/already registered/i);
38+
});
39+
40+
it('routes a tool protection to the adapter whose type owns it', () => {
41+
const registry = new AuthorizationServerRegistry();
42+
registry.register(oidc());
43+
const adapter = registry.resolve({
44+
toolName: 'vault.read',
45+
requirement: { type: 'oauth', provider: 'generic-oidc' },
46+
});
47+
expect(adapter?.type).toBe('oauth');
48+
});
49+
50+
it('resolves to undefined for an unprotected tool', () => {
51+
const registry = new AuthorizationServerRegistry();
52+
registry.register(oidc());
53+
expect(registry.resolve({ toolName: 'ping', requirement: { type: 'none' } })).toBeUndefined();
54+
});
55+
56+
it('seals against further registration', () => {
57+
const registry = new AuthorizationServerRegistry();
58+
registry.register(oidc());
59+
registry.seal();
60+
expect(registry.isSealed()).toBe(true);
61+
const another: AuthorizationServerAdapter = { ...oidc(), type: 'idv' } as AuthorizationServerAdapter;
62+
expect(() => registry.register(another)).toThrow(/sealed/i);
63+
});
64+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
AuthorizationRequirementSchema,
4+
ToolProtectionSchema,
5+
type AuthorizationRequirement,
6+
} from '../requirement.js';
7+
8+
/**
9+
* The AuthorizationRequirement union is the neutral description of *what kind*
10+
* of authorization a tool needs, independent of any concrete authorization
11+
* server. It is the input vocabulary the AuthorizationServerAdapter dispatches
12+
* on. These tests pin the discriminants and the fail-closed parsing.
13+
*/
14+
describe('AuthorizationRequirement', () => {
15+
it('accepts an oauth requirement with provider and optional scopes', () => {
16+
const parsed = AuthorizationRequirementSchema.parse({
17+
type: 'oauth',
18+
provider: 'generic-oidc',
19+
requiredScopes: ['vault:read'],
20+
});
21+
expect(parsed.type).toBe('oauth');
22+
});
23+
24+
it('accepts the none requirement (no authorization needed)', () => {
25+
expect(AuthorizationRequirementSchema.parse({ type: 'none' }).type).toBe('none');
26+
});
27+
28+
it.each(['mdl', 'idv', 'credential'])('accepts the %s requirement discriminant', (type) => {
29+
expect(AuthorizationRequirementSchema.parse({ type }).type).toBe(type);
30+
});
31+
32+
it('rejects an unknown discriminant', () => {
33+
expect(() => AuthorizationRequirementSchema.parse({ type: 'telepathy' })).toThrow();
34+
});
35+
36+
it('rejects an oauth requirement missing its provider', () => {
37+
expect(() => AuthorizationRequirementSchema.parse({ type: 'oauth' })).toThrow();
38+
});
39+
40+
it('narrows by discriminant at the type level', () => {
41+
const req: AuthorizationRequirement = { type: 'oauth', provider: 'generic-oidc' };
42+
if (req.type === 'oauth') {
43+
// `provider` is only reachable on the oauth variant — compile-time proof
44+
// the union discriminates correctly.
45+
expect(req.provider).toBe('generic-oidc');
46+
}
47+
});
48+
});
49+
50+
describe('ToolProtection', () => {
51+
it('parses a tool protection declaring a required authorization', () => {
52+
const parsed = ToolProtectionSchema.parse({
53+
toolName: 'vault.read',
54+
requirement: { type: 'oauth', provider: 'generic-oidc', requiredScopes: ['vault:read'] },
55+
});
56+
expect(parsed.toolName).toBe('vault.read');
57+
expect(parsed.requirement.type).toBe('oauth');
58+
});
59+
60+
it('rejects a tool protection with no tool name', () => {
61+
expect(() =>
62+
ToolProtectionSchema.parse({ requirement: { type: 'none' } }),
63+
).toThrow();
64+
});
65+
});

0 commit comments

Comments
 (0)