Skip to content

Commit 4ed8767

Browse files
authored
feat(auth0-api-js): add ProtectedResourceMetadata (#60)
* adds ProtectedResourceMetadata class * fix the export * update README to include how to serve PRM * refactor: builder pattern * update README for ProtectedResourceMetadataBuilder usage * fix readme to use single quotes
1 parent 89ad854 commit 4ed8767

4 files changed

Lines changed: 626 additions & 9 deletions

File tree

packages/auth0-api-js/README.md

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,10 @@ This library requires Node.js 20 LTS and newer LTS versions.
2626

2727
Create an instance of the `ApiClient`. This instance will be imported and used anywhere we need access to the methods.
2828

29-
3029
```ts
3130
import { ApiClient } from '@auth0/auth0-api-js';
3231

33-
const apiClient = new apiClient({
32+
const apiClient = new ApiClient({
3433
domain: '<AUTH0_DOMAIN>',
3534
audience: '<AUTH0_AUDIENCE>',
3635
});
@@ -44,29 +43,64 @@ The `AUTH0_AUDIENCE` is the identifier of the API. You can find this in the API
4443
The SDK's `verifyAccessToken` method can be used to verify the access token.
4544

4645
```ts
47-
const apiClient = new apiClient({
46+
const apiClient = new ApiClient({
4847
domain: '<AUTH0_DOMAIN>',
4948
audience: '<AUTH0_AUDIENCE>',
5049
});
5150

5251
const accessToken = '...';
53-
const decodedAndVerfiedToken = await apiClient.verifyAccessToken({
54-
accessToken
52+
const decodedAndVerifiedToken = await apiClient.verifyAccessToken({
53+
accessToken,
5554
});
5655
```
5756

58-
the SDK automatically validates claims like `iss`, `aud`, `exp`, and `nbf`, you can also pass additional claims to be required by configuring `requiredClaims`:
57+
The SDK automatically validates claims like `iss`, `aud`, `exp`, and `nbf`. You can also pass additional claims to be required by configuring `requiredClaims`:
5958

6059
```ts
61-
const apiClient = new apiClient({
60+
const apiClient = new ApiClient({
6261
domain: '<AUTH0_DOMAIN>',
6362
audience: '<AUTH0_AUDIENCE>',
6463
});
6564

6665
const accessToken = '...';
67-
const decodedAndVerfiedToken = await apiClient.verifyAccessToken({
66+
const decodedAndVerifiedToken = await apiClient.verifyAccessToken({
6867
accessToken,
69-
requiredClaims: ['my_custom_claim']
68+
requiredClaims: ['my_custom_claim'],
69+
});
70+
```
71+
72+
### 4. Protected Resource Metadata (RFC 9728)
73+
74+
The SDK supports OAuth 2.0 Protected Resource Metadata as defined in [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728):
75+
76+
```ts
77+
import {
78+
ProtectedResourceMetadataBuilder,
79+
AuthorizationScheme,
80+
TokenEndpointAuthMethod,
81+
SigningAlgorithm,
82+
} from '@auth0/auth0-api-js';
83+
84+
const resourceServerUrl = 'https://api.example.com';
85+
const authServers = ['https://your-tenant.us.auth0.com'];
86+
87+
const metadata = new ProtectedResourceMetadataBuilder(resourceServerUrl, authServers)
88+
.withBearerMethodsSupported([AuthorizationScheme.BEARER])
89+
.withTokenEndpointAuthMethodsSupported([
90+
TokenEndpointAuthMethod.CLIENT_SECRET_BASIC,
91+
TokenEndpointAuthMethod.CLIENT_SECRET_POST,
92+
TokenEndpointAuthMethod.PRIVATE_KEY_JWT,
93+
])
94+
.withTokenEndpointAuthSigningAlgValuesSupported([
95+
SigningAlgorithm.RS256,
96+
SigningAlgorithm.ES256,
97+
])
98+
.withScopesSupported(['read', 'write', 'admin'])
99+
.build();
100+
101+
// Serve metadata from the standard RFC 9728 endpoint
102+
app.get('/.well-known/oauth-protected-resource', (req, res) => {
103+
res.json(metadata.toJSON());
70104
});
71105
```
72106

packages/auth0-api-js/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { ApiClient } from './api-client.js';
2+
export * from './protected-resource-metadata.js';
23
export * from './errors.js';
34
export * from './types.js';
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
ProtectedResourceMetadataBuilder,
4+
AuthorizationScheme,
5+
TokenEndpointAuthMethod,
6+
SigningAlgorithm,
7+
} from "./protected-resource-metadata.js";
8+
import { MissingRequiredArgumentError } from "./errors.js";
9+
10+
describe("ProtectedResourceMetadataBuilder", () => {
11+
const VALID_RESOURCE = "http://localhost:3001/mcp";
12+
const VALID_AUTH_SERVERS = ["https://a0-mcp.us.auth0.com"];
13+
14+
describe("constructor", () => {
15+
it("should create builder instance with required parameters", () => {
16+
const builder = new ProtectedResourceMetadataBuilder(
17+
VALID_RESOURCE,
18+
VALID_AUTH_SERVERS
19+
);
20+
const metadata = builder.build();
21+
const json = metadata.toJSON();
22+
23+
expect(json.resource).toBe(VALID_RESOURCE);
24+
expect(json.authorization_servers).toEqual(VALID_AUTH_SERVERS);
25+
expect(json.authorization_servers).not.toBe(VALID_AUTH_SERVERS); // Should be a copy
26+
});
27+
28+
it("should throw error for invalid parameters", () => {
29+
expect(
30+
() => new ProtectedResourceMetadataBuilder("", VALID_AUTH_SERVERS)
31+
).toThrow(MissingRequiredArgumentError);
32+
33+
expect(
34+
() => new ProtectedResourceMetadataBuilder(" ", VALID_AUTH_SERVERS)
35+
).toThrow(MissingRequiredArgumentError);
36+
37+
expect(
38+
() => new ProtectedResourceMetadataBuilder(VALID_RESOURCE, [])
39+
).toThrow(MissingRequiredArgumentError);
40+
});
41+
});
42+
43+
describe("builder methods", () => {
44+
it("should build complex metadata with fluent interface", () => {
45+
const metadata = new ProtectedResourceMetadataBuilder(
46+
VALID_RESOURCE,
47+
VALID_AUTH_SERVERS
48+
)
49+
.withBearerMethodsSupported([AuthorizationScheme.BEARER])
50+
.withTokenEndpointAuthMethodsSupported([
51+
TokenEndpointAuthMethod.CLIENT_SECRET_BASIC,
52+
TokenEndpointAuthMethod.CLIENT_SECRET_POST,
53+
TokenEndpointAuthMethod.PRIVATE_KEY_JWT,
54+
])
55+
.withTokenEndpointAuthSigningAlgValuesSupported([
56+
SigningAlgorithm.RS256,
57+
SigningAlgorithm.ES256,
58+
])
59+
.withScopesSupported(["read", "write", "admin"])
60+
.build();
61+
62+
const json = metadata.toJSON();
63+
expect(json.resource).toBe(VALID_RESOURCE);
64+
expect(json.authorization_servers).toEqual(VALID_AUTH_SERVERS);
65+
expect(json.bearer_methods_supported).toEqual([
66+
AuthorizationScheme.BEARER,
67+
]);
68+
expect(json.token_endpoint_auth_methods_supported).toEqual([
69+
TokenEndpointAuthMethod.CLIENT_SECRET_BASIC,
70+
TokenEndpointAuthMethod.CLIENT_SECRET_POST,
71+
TokenEndpointAuthMethod.PRIVATE_KEY_JWT,
72+
]);
73+
expect(json.token_endpoint_auth_signing_alg_values_supported).toEqual([
74+
SigningAlgorithm.RS256,
75+
SigningAlgorithm.ES256,
76+
]);
77+
expect(json.scopes_supported).toEqual(["read", "write", "admin"]);
78+
});
79+
80+
it("should support builder chaining", () => {
81+
const baseBuilder = new ProtectedResourceMetadataBuilder(
82+
VALID_RESOURCE,
83+
VALID_AUTH_SERVERS
84+
);
85+
const scopes = ["read", "write"];
86+
const builderWithScopes = baseBuilder.withScopesSupported(scopes);
87+
88+
// Both references point to the same builder instance
89+
expect(builderWithScopes).toBe(baseBuilder);
90+
91+
const metadata = baseBuilder.build();
92+
const json = metadata.toJSON();
93+
94+
// The builder should have the scopes that were added
95+
expect(json.scopes_supported).toEqual(scopes);
96+
97+
// Test array cloning - returned arrays should be copies
98+
expect(json.scopes_supported).not.toBe(scopes);
99+
expect(json.authorization_servers).not.toBe(VALID_AUTH_SERVERS);
100+
});
101+
});
102+
103+
describe("serialization", () => {
104+
it("should convert to JSON with only defined properties", () => {
105+
const jwksUri = "https://example.com/.well-known/jwks.json";
106+
const metadata = new ProtectedResourceMetadataBuilder(
107+
VALID_RESOURCE,
108+
VALID_AUTH_SERVERS
109+
)
110+
.withJwksUri(jwksUri)
111+
.withScopesSupported(["read", "write"])
112+
.build();
113+
114+
const json = metadata.toJSON();
115+
116+
expect(json).toEqual({
117+
resource: VALID_RESOURCE,
118+
authorization_servers: VALID_AUTH_SERVERS,
119+
jwks_uri: jwksUri,
120+
scopes_supported: ["read", "write"],
121+
});
122+
});
123+
124+
it("should serialize metadata correctly", () => {
125+
const metadata = new ProtectedResourceMetadataBuilder(
126+
VALID_RESOURCE,
127+
VALID_AUTH_SERVERS
128+
)
129+
.withScopesSupported(["read", "write"])
130+
.withBearerMethodsSupported([AuthorizationScheme.BEARER])
131+
.withTokenEndpointAuthMethodsSupported([
132+
TokenEndpointAuthMethod.CLIENT_SECRET_BASIC,
133+
])
134+
.build();
135+
136+
const json = metadata.toJSON();
137+
138+
expect(json.resource).toBe(VALID_RESOURCE);
139+
expect(json.authorization_servers).toEqual(VALID_AUTH_SERVERS);
140+
expect(json.scopes_supported).toEqual(["read", "write"]);
141+
expect(json.bearer_methods_supported).toEqual([
142+
AuthorizationScheme.BEARER,
143+
]);
144+
expect(json.token_endpoint_auth_methods_supported).toEqual([
145+
TokenEndpointAuthMethod.CLIENT_SECRET_BASIC,
146+
]);
147+
148+
// Arrays in JSON should be copies, not the same references
149+
expect(json.authorization_servers).not.toBe(VALID_AUTH_SERVERS);
150+
expect(json.scopes_supported).not.toBe(["read", "write"]);
151+
});
152+
153+
it("should only include defined properties in JSON output", () => {
154+
const metadata = new ProtectedResourceMetadataBuilder(
155+
VALID_RESOURCE,
156+
VALID_AUTH_SERVERS
157+
)
158+
.withScopesSupported(["read"])
159+
.build();
160+
161+
const json = metadata.toJSON();
162+
163+
expect(json).toHaveProperty("resource");
164+
expect(json).toHaveProperty("authorization_servers");
165+
expect(json).toHaveProperty("scopes_supported");
166+
expect(json).not.toHaveProperty("jwks_uri");
167+
expect(json).not.toHaveProperty("bearer_methods_supported");
168+
});
169+
});
170+
171+
describe("data integrity", () => {
172+
it("should return immutable JSON arrays", () => {
173+
const metadata = new ProtectedResourceMetadataBuilder(
174+
VALID_RESOURCE,
175+
VALID_AUTH_SERVERS
176+
)
177+
.withScopesSupported(["read", "write"])
178+
.build();
179+
180+
const json = metadata.toJSON();
181+
const json2 = metadata.toJSON();
182+
183+
// Each call to toJSON should return new array instances
184+
expect(json.authorization_servers).not.toBe(json2.authorization_servers);
185+
expect(json.scopes_supported).not.toBe(json2.scopes_supported);
186+
187+
// But with same content
188+
expect(json.authorization_servers).toEqual(json2.authorization_servers);
189+
expect(json.scopes_supported).toEqual(json2.scopes_supported);
190+
});
191+
192+
it("should not share array references with input data", () => {
193+
const scopes = ["read", "write"];
194+
const authServers = [...VALID_AUTH_SERVERS];
195+
196+
const metadata = new ProtectedResourceMetadataBuilder(
197+
VALID_RESOURCE,
198+
authServers
199+
)
200+
.withScopesSupported(scopes)
201+
.build();
202+
203+
const json = metadata.toJSON();
204+
205+
// Modifying original arrays should not affect the metadata
206+
scopes.push("admin");
207+
authServers.push("new-server");
208+
209+
expect(json.scopes_supported).toEqual(["read", "write"]);
210+
expect(json.authorization_servers).toEqual(VALID_AUTH_SERVERS);
211+
});
212+
});
213+
});

0 commit comments

Comments
 (0)