Skip to content

Commit

Permalink
add dpop encoding utils
Browse files Browse the repository at this point in the history
alphabetize import and clarify base64 test case

add createJWT function

add buildDPoPHeaders function and export it
  • Loading branch information
jshawl committed Feb 22, 2024
1 parent c97c16f commit fcf639d
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 0 deletions.
93 changes: 93 additions & 0 deletions src/dpop.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,41 @@ type KeyPair = {|
publicKey: mixed,
|};

type DPoPParameters = {|
accessToken?: string,
method: string,
nonce?: string,
uri: string,
|};

type JWTParameters = {|
...KeyPair,
...DPoPParameters,
|};

type DPoPHeaders = {|
Authorization?: string,
DPoP: string,
|};

type GenerateKeyPair = () => Promise<KeyPair>;

type CreateJWT = (JWTParameters) => Promise<string>;

type BuildDPoPHeaders = (DPoPParameters) => Promise<DPoPHeaders>;

// https://datatracker.ietf.org/doc/html/rfc7518#section-3.1
const KEY_OPTIONS = {
alg: "ES256",
create: {
name: "ECDSA",
namedCurve: "P-256",
},
extractable: false,
sign: {
name: "ECDSA",
hash: { name: "SHA-256" },
},
usages: ["sign", "verify"],
};

Expand Down Expand Up @@ -67,4 +93,71 @@ export const jsonWebKeyThumbprint = async (jwk: Object): Promise<string> => {
return await sha256(JSON.stringify({ crv, e, kty, n, x, y }));
};

export const createJWT: CreateJWT = async ({
accessToken,
method,
nonce,
publicKey,
privateKey,
uri,
}) => {
const jwk = await window.crypto.subtle.exportKey("jwk", publicKey);

const header = {
alg: KEY_OPTIONS.alg,
typ: "dpop+jwt",
jwk,
};

const encodedHeader = base64encodeUrlSafe(JSON.stringify(header));

const payload = {
ath: accessToken ? await sha256(accessToken) : undefined,
cnf: {
jkt: await jsonWebKeyThumbprint(jwk),
},
htm: method,
htu: uri,
iat: Math.floor(new Date() / 1000),
jti: window.crypto.randomUUID(),
nonce,
};

const encodedPayload = base64encodeUrlSafe(JSON.stringify(payload));

const signature = await window.crypto.subtle.sign(
KEY_OPTIONS.sign,
privateKey,
stringToBytes(`${encodedHeader}.${encodedPayload}`)
);

const encodedSignature = base64encodeUrlSafe(
bytesToString(new Uint8Array(signature))
);

return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
};

export const buildDPoPHeaders: BuildDPoPHeaders = async ({
accessToken,
method,
uri,
nonce,
}) => {
const { privateKey, publicKey } = await generateKeyPair();
const jwt = await createJWT({
accessToken,
method,
uri,
nonce,
publicKey,
privateKey,
});
// https://datatracker.ietf.org/doc/html/rfc9449#name-dpop-protected-resource-req
return {
...(accessToken && { Authorization: `DPoP ${accessToken}` }),
DPoP: jwt,
};
};

/* eslint-enable promise/no-native, no-restricted-globals */
76 changes: 76 additions & 0 deletions src/dpop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { describe, expect, it } from "vitest";
import {
base64decodeUrlSafe,
base64encodeUrlSafe,
buildDPoPHeaders,
bytesToString,
createJWT,
generateKeyPair,
jsonWebKeyThumbprint,
sha256,
Expand Down Expand Up @@ -72,4 +74,78 @@ describe("DPoP", () => {
);
});
});
describe("createJWT", async () => {
const method = "GET";
const nonce = window.crypto.randomUUID();
const uri = "https://example.com/oauth2/token";
const { publicKey, privateKey } = await generateKeyPair();
const jwt = await createJWT({
accessToken: "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU",
method,
nonce,
publicKey,
privateKey,
uri,
});
const [encodedHeader, encodedPayload, encodedSignature] = jwt.split(".");
it("has a valid header", () => {
const header = JSON.parse(base64decodeUrlSafe(encodedHeader));
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-2.2
expect(header.typ).toBe("dpop+jwt");
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-2.4
expect(header.alg).toBe("ES256");
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-2.6
expect(header.jwk.x).toBeTruthy();
});
it("has a valid payload", () => {
const payload = JSON.parse(base64decodeUrlSafe(encodedPayload));
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-4.4
expect(payload.htm).toBe(method);
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-4.6
expect(payload.htu).toBe(uri);
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-4.8
expect(typeof payload.iat).toBe("number");
// https://datatracker.ietf.org/doc/html/rfc9449#section-6.1-2.2
expect(typeof payload.cnf.jkt).toEqual("string");
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-6.2
expect(payload.ath).toBe("fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo");
// https://datatracker.ietf.org/doc/html/rfc9449#section-4.2-7
expect(payload.nonce).toBe(nonce);
});
it("has a valid signature", async () => {
const signature = stringToBytes(base64decodeUrlSafe(encodedSignature));
const verified = await window.crypto.subtle.verify(
{
name: "ECDSA",
hash: { name: "SHA-256" },
},
publicKey,
signature,
`${encodedHeader}.${encodedPayload}`
);
expect(verified).toBe(true);
});
});
describe("buildDPoPHeaders", () => {
it("includes an authorization header if an access token is present", async () => {
const accessToken = window.crypto.randomUUID();
const options = {
method: "POST",
uri: "https://example.com/oauth2/token",
};
const headers = await buildDPoPHeaders(options);
expect(headers.Authorization).toBe(undefined);
const headers2 = await buildDPoPHeaders({ ...options, accessToken });
expect(headers2.Authorization).toBe(`DPoP ${accessToken}`);
});
it("always includes a DPoP header", async () => {
const options = {
method: "POST",
uri: "https://example.com/oauth2/token",
};
const headers = await buildDPoPHeaders(options);
expect(headers.DPoP).toBeTruthy();
expect(typeof headers.DPoP).toBe("string");
});
});
});
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export * from "./graphql";
export * from "./domains";
export * from "./tracking";
export * from "./utils";
export { buildDPoPHeaders } from "./dpop";

0 comments on commit fcf639d

Please sign in to comment.