Skip to content

Commit 3669910

Browse files
author
Matthieu Bosquet
committed
Phase in ath
1 parent 6393ebe commit 3669910

10 files changed

Lines changed: 124 additions & 20 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,5 @@ The `solidOidcAccessTokenVerifier` function takes an authorization header which
6565
- Improve default caching? Assess other libraries that might be used.
6666
- Evolve the type guards and the type guard library.
6767
- Allow http over tls on all WebIDs instead of enforcing https as per: https://github.com/solid/authentication-panel/issues/114.
68+
- Enforce client ID when support is wide enough as per: https://solid.github.io/solid-oidc/#tokens-access
69+
- Enforce DPoP ath claim when support is wide enough as per: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop-03#section-4.2

package-lock.json

Lines changed: 16 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@solid/access-token-verifier",
3-
"version": "0.9.4",
3+
"version": "0.10.0",
44
"description": "Verifies Solid OIDC access tokens via their webid claim, and thus asserts ownership of a WebID.",
55
"license": "MIT",
66
"keywords": [
@@ -60,9 +60,9 @@
6060
},
6161
"dependencies": {
6262
"cross-fetch": "^3.1.4",
63-
"jose": "^3.14.0",
63+
"jose": "^3.14.3",
6464
"lru-cache": "^6.0.0",
65-
"n3": "^1.11.0",
65+
"n3": "^1.11.1",
6666
"rdf-dereference": "^1.8.0",
6767
"ts-guards": "^0.5.1"
6868
}

src/algorithm/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./isValidAthClaim";

src/algorithm/isValidAthClaim.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createHash } from "crypto";
2+
import { encode as base64UrlEncode } from "jose/util/base64url";
3+
4+
/**
5+
* Verifies the DPoP Proof ath claim
6+
* The base64url encoded SHA-256 hash of the ASCII encoding of the associated access token's value.
7+
* See also: https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop-03#section-4.2
8+
*/
9+
export function isValidAthClaim(
10+
accessToken: string,
11+
athClaim: string
12+
): boolean {
13+
return (
14+
base64UrlEncode(
15+
createHash("sha256").update(Buffer.from(accessToken, "ascii")).digest()
16+
) === athClaim
17+
);
18+
}

src/lib/DPoP.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import EmbeddedJWK from "jose/jwk/embedded";
22
import calculateThumbprint from "jose/jwk/thumbprint";
33
import jwtVerify from "jose/jwt/verify";
44
import { asserts } from "ts-guards";
5+
import { isValidAthClaim } from "../algorithm";
56
import { isSolidDPoPBoundAccessTokenPayload, isDPoPToken } from "../guard";
67
import type {
78
SolidAccessToken,
@@ -38,6 +39,14 @@ async function isValidProof(
3839
asserts.isLiteral(dpop.payload.htm, method);
3940
asserts.isLiteral(dpop.payload.htu, url);
4041
asserts.isLiteral(isDuplicateJTI(dpop.payload.jti), false);
42+
43+
// TODO: Phased-in ath becomes enforced
44+
if (typeof dpop.payload.ath === "string" && dpop.payload.ath) {
45+
asserts.isLiteral(
46+
isValidAthClaim(JSON.stringify(accessToken), dpop.payload.ath),
47+
true
48+
);
49+
}
4150
}
4251

4352
/**

src/type/DPoPToken.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,6 @@ export interface DPoPTokenPayload {
2222
htu: string;
2323
iat: number;
2424
jti: string;
25+
// TODO: Phased-in ath becomes enforced
26+
ath?: string;
2527
}

src/type/SolidAccessToken.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface SolidAccessTokenHeader {
1919

2020
export interface SolidAccessTokenPayload {
2121
aud: "solid" | string[];
22+
// TODO: Phased-in client_id becomes enforced
2223
client_id?: string;
2324
exp: number;
2425
iat: number;

test/DPoP.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import jwtVerify from "jose/jwt/verify";
2+
import { isValidAthClaim } from "../src/algorithm/isValidAthClaim";
23
import { verify } from "../src/lib/DPoP";
3-
import type { DPoPToken } from "../src/type";
4+
import type { DPoPToken, DPoPTokenPayload } from "../src/type";
45
import { encodeToken } from "./fixture/EncodeToken";
56

67
/* eslint-disable @typescript-eslint/no-explicit-any */
78
jest.mock("jose/jwt/verify");
9+
jest.mock("../src/algorithm/isValidAthClaim");
810

911
const dpop: DPoPToken = {
1012
header: {
@@ -27,6 +29,14 @@ const dpop: DPoPToken = {
2729
"lNhmpAX1WwmpBvwhok4E74kWCiGBNdavjLAeevGy32H3dbF0Jbri69Nm2ukkwb-uyUI4AUg1JSskfWIyo4UCbQ",
2830
};
2931

32+
const dpopPayloadWithAth: DPoPTokenPayload = {
33+
jti: "e1j3V_bKic8-LAEB",
34+
htm: "GET",
35+
htu: "https://resource.example.org/protectedresource",
36+
iat: 1562262618,
37+
ath: "bla",
38+
};
39+
3040
const dpopRSA: DPoPToken = {
3141
header: {
3242
typ: "dpop+jwt",
@@ -79,6 +89,54 @@ describe("DPoP proof", () => {
7989
).toStrictEqual(dpop);
8090
});
8191

92+
it("Checks conforming proof with EC Key and ath claim", async () => {
93+
(jwtVerify as jest.Mock).mockResolvedValueOnce({
94+
payload: dpopPayloadWithAth,
95+
protectedHeader: dpop.header,
96+
});
97+
(isValidAthClaim as jest.Mock).mockReturnValueOnce(true);
98+
99+
expect(
100+
await verify(
101+
encodeToken(dpop),
102+
{
103+
payload: {
104+
cnf: { jkt: "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" },
105+
},
106+
} as any,
107+
"GET",
108+
"https://resource.example.org/protectedresource",
109+
() => false
110+
)
111+
).toStrictEqual({
112+
header: dpop.header,
113+
payload: dpopPayloadWithAth,
114+
signature: dpop.signature,
115+
});
116+
});
117+
118+
it("Throws on invalid ath claim", async () => {
119+
(jwtVerify as jest.Mock).mockResolvedValueOnce({
120+
payload: dpopPayloadWithAth,
121+
protectedHeader: dpop.header,
122+
});
123+
(isValidAthClaim as jest.Mock).mockReturnValueOnce(false);
124+
125+
await expect(
126+
verify(
127+
encodeToken(dpop),
128+
{
129+
payload: {
130+
cnf: { jkt: "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" },
131+
},
132+
} as any,
133+
"GET",
134+
"https://resource.example.org/protectedresource",
135+
() => false
136+
)
137+
).rejects.toThrow("Expected true, got:\nfalse");
138+
});
139+
82140
it("Checks conforming proof with RSA Key", async () => {
83141
(jwtVerify as jest.Mock).mockResolvedValueOnce({
84142
payload: dpopRSA.payload,

test/isValidAthClaim.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { isValidAthClaim } from "../src/algorithm/isValidAthClaim";
2+
3+
// Example data extracted from https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop-03#section-7.1
4+
describe("isValidAthClaim", () => {
5+
it("Validates a correct claim", () => {
6+
expect(
7+
isValidAthClaim(
8+
"Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU",
9+
"fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
10+
)
11+
).toBe(true);
12+
});
13+
});

0 commit comments

Comments
 (0)