Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implemented the DPoP token exchange (#411)
Browse files Browse the repository at this point in the history
- This implements the OAuth2 code exchange step: after an auth code has been returned by the IdP through redirection, the client can use the obtained auth code to get an access token. If the request has a DPoP header, the returned token is bound to the DPoP key.
- The mockJwk reference is unefined when setting up the mock, but the mockJwk() function is defined when *calling* the mock. Thanks @Vinnl !
- The oidc module is independant from solid, so it should not depend on the core module, which is meant to be solid-specific. this implies some redundancy in the types implemented in both places, but that means that these types may evolve independantly, while still getting errors in case of incompatibility, which is a good thing.
- The endpoint returns a token_type field, which can be used to verify that the token is of the requested type (Bearer or DPoP)

Co-authored-by: Vincent <[email protected]>
NSeydoux and Vinnl committed Oct 21, 2020
1 parent f8c6610 commit 4dd0ef5
Showing 12 changed files with 916 additions and 79 deletions.
10 changes: 10 additions & 0 deletions packages/oidc-dpop-client-browser/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions packages/oidc-dpop-client-browser/package.json
Original file line number Diff line number Diff line change
@@ -29,15 +29,16 @@
"@rollup/plugin-node-resolve": "^9.0.0",
"cross-fetch": "^3.0.6",
"rollup": "^2.15.0",
"rollup-plugin-typescript2": "^0.27.1",
"rollup-plugin-node-polyfills": "^0.2.1"
"rollup-plugin-node-polyfills": "^0.2.1",
"rollup-plugin-typescript2": "^0.27.1"
},
"dependencies": {
"@inrupt/solid-client-authn-core": "^0.2.0",
"@types/form-urlencoded": "^2.0.1",
"@types/jsonwebtoken": "^8.5.0",
"@types/node-jose": "^1.1.5",
"@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.0",
"form-urlencoded": "^4.2.1",
"jose": "^2.0.2",
"jsonwebtoken": "^8.5.1",
"node-jose": "^2.0.0",
73 changes: 73 additions & 0 deletions packages/oidc-dpop-client-browser/src/common/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* Copyright 2020 Inrupt Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import URL from "url-parse";

export interface IIssuerConfig {
issuer: URL;
authorizationEndpoint: URL;
tokenEndpoint: URL;
userinfoEndpoint?: URL;
jwksUri: URL;
registrationEndpoint?: URL;
scopesSupported?: string[];
responseTypesSupported?: string[];
responseModesSupported?: string[];
grantTypesSupported?: string[];
acrValuesSupported?: string[];
subjectTypesSupported: string[];
idTokenSigningAlgValuesSupported?: string[];
idTokenEncryptionAlgValuesSupported?: string[];
idTokenEncryptionEncValuesSupported?: string[];
userinfoSigningAlgValuesSupported?: string[];
userinfoEncryptionAlgValuesSupported?: string[];
userinfoEncryptionEncValuesSupported?: string[];
requestObjectSigningAlgValuesSupported?: string[];
requestObjectEncryptionAlgValuesSupported?: string[];
requestObjectEncryptionEncValuesSupported?: string[];
tokenEndpointAuthMethodsSupported?: string[];
tokenEndpointAuthSigningAlgValuesSupported?: string[];
displayValuesSupported?: string[];
claimTypesSupported?: string[];
claimsSupported: string[];
serviceDocumentation?: string[];
claimsLocalesSupported?: boolean;
uiLocalesSupported?: boolean;
claimsParameterSupported?: boolean;
requestParameterSupported?: boolean;
requestUriParameterSupported?: boolean;
requireRequestUriRegistration?: boolean;
opPolicyUri?: URL;
opTosUri?: URL;
}

export interface IClient {
clientId: string;
clientSecret?: string;
clientName?: string;
}

export interface IClientRegistrarOptions {
sessionId: string;
clientName?: string;
redirectUrl?: URL;
registrationAccessToken?: string;
}
Original file line number Diff line number Diff line change
@@ -20,10 +20,7 @@
*/

import { it, describe } from "@jest/globals";
import {
IIssuerConfig,
IClientRegistrarOptions,
} from "@inrupt/solid-client-authn-core";
import { IIssuerConfig, IClientRegistrarOptions } from "../common/types";
import URL from "url-parse";
import { registerClient } from "./clientRegistrar";
import { Response } from "cross-fetch";
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ import {
IIssuerConfig,
IClient,
IClientRegistrarOptions,
} from "@inrupt/solid-client-authn-core";
} from "../common/types";

function processErrorResponse(
// The type is any here because the object is parsed from a JSON response
30 changes: 1 addition & 29 deletions packages/oidc-dpop-client-browser/src/dpop/dpop.spec.ts
Original file line number Diff line number Diff line change
@@ -25,38 +25,10 @@ import { describe, it } from "@jest/globals";
import {
createDpopHeader,
decodeJwt,
generateJwk,
generateJwkForDpop,
generateJwkRsa,
normalizeHttpUriClaim,
signJwt,
} from "./dpop";

describe("generateJwk", () => {
it("can generate a RSA-based JWK", async () => {
const key = await generateJwk("RSA");
expect(key.kty).toEqual("RSA");
});

it("can generate an elliptic curve-based JWK", async () => {
const key = await generateJwk("EC", "P-256");
expect(key.kty).toEqual("EC");
});
});

describe("generateJwkForDpop", () => {
it("generates an elliptic curve-base key, which is a sensible default for DPoP", async () => {
const key = await generateJwkForDpop();
expect(key.kty).toEqual("EC");
});
});

describe("generateJwkRsa", () => {
it("generates an RSA key", async () => {
const key = await generateJwkRsa();
expect(key.kty).toEqual("RSA");
});
});
import { generateJwk, generateJwkForDpop } from "./keyGeneration";

describe("signJwt/decodeJwt", () => {
it("generates a JWT that can be decoded without signature verification", async () => {
41 changes: 1 addition & 40 deletions packages/oidc-dpop-client-browser/src/dpop/dpop.ts
Original file line number Diff line number Diff line change
@@ -21,35 +21,10 @@

import URL from "url-parse";
import { JWK } from "node-jose";
import {
BasicParameters,
ECCurve,
JSONWebKey,
JWKECKey,
JWKOctKey,
JWKOKPKey,
JWKRSAKey,
OKPCurve,
} from "jose";
import { JSONWebKey, JWKECKey, JWKOctKey, JWKOKPKey, JWKRSAKey } from "jose";
import JWT, { VerifyOptions } from "jsonwebtoken";
import { v4 } from "uuid";

/**
* Generates a Json Web Key
* @param kty Key type
* @param crvBitlength Curve length (only relevant for elliptic curve algorithms)
* @param parameters
* @hidden
*/
export async function generateJwk(
kty: "EC" | "RSA",
crvBitlength?: ECCurve | OKPCurve | number,
parameters?: BasicParameters
): Promise<JSONWebKey> {
const key = await JWK.createKey(kty, crvBitlength, parameters);
return key.toJSON(true) as JSONWebKey;
}

/**
* Generates a Json Web Token (https://tools.ietf.org/html/rfc7519) containing
* the provided payload and using the signature algorithm specified in the options.
@@ -148,17 +123,3 @@ export async function createDpopHeader(
}
);
}

/**
* Generates a JSON Web Key suitable to be used to sign HTTP request headers.
*/
export async function generateJwkForDpop(): Promise<JSONWebKey> {
return generateJwk("EC", "P-256", { alg: "ES256" });
}

/**
* Generates a JSON Web Key based on the RSA algorithm
*/
export async function generateJwkRsa(): Promise<JSONWebKey> {
return generateJwk("RSA");
}
54 changes: 54 additions & 0 deletions packages/oidc-dpop-client-browser/src/dpop/keyGeneration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2020 Inrupt Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import { describe, it } from "@jest/globals";

import {
generateJwk,
generateJwkForDpop,
generateJwkRsa,
} from "./keyGeneration";

describe("generateJwk", () => {
it("can generate a RSA-based JWK", async () => {
const key = await generateJwk("RSA");
expect(key.kty).toEqual("RSA");
});

it("can generate an elliptic curve-based JWK", async () => {
const key = await generateJwk("EC", "P-256");
expect(key.kty).toEqual("EC");
});
});

describe("generateJwkForDpop", () => {
it("generates an elliptic curve-base key, which is a sensible default for DPoP", async () => {
const key = await generateJwkForDpop();
expect(key.kty).toEqual("EC");
});
});

describe("generateJwkRsa", () => {
it("generates an RSA key", async () => {
const key = await generateJwkRsa();
expect(key.kty).toEqual("RSA");
});
});
53 changes: 53 additions & 0 deletions packages/oidc-dpop-client-browser/src/dpop/keyGeneration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Inrupt Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import { JWK } from "node-jose";
import { BasicParameters, ECCurve, JSONWebKey, OKPCurve } from "jose";

/**
* Generates a Json Web Key
* @param kty Key type
* @param crvBitlength Curve length (only relevant for elliptic curve algorithms)
* @param parameters
* @hidden
*/
export async function generateJwk(
kty: "EC" | "RSA",
crvBitlength?: ECCurve | OKPCurve | number,
parameters?: BasicParameters
): Promise<JSONWebKey> {
const key = await JWK.createKey(kty, crvBitlength, parameters);
return key.toJSON(true) as JSONWebKey;
}

/**
* Generates a JSON Web Key suitable to be used to sign HTTP request headers.
*/
export async function generateJwkForDpop(): Promise<JSONWebKey> {
return generateJwk("EC", "P-256", { alg: "ES256" });
}

/**
* Generates a JSON Web Key based on the RSA algorithm
*/
export async function generateJwkRsa(): Promise<JSONWebKey> {
return generateJwk("RSA");
}
473 changes: 473 additions & 0 deletions packages/oidc-dpop-client-browser/src/dpop/tokenExchange.spec.ts

Large diffs are not rendered by default.

234 changes: 234 additions & 0 deletions packages/oidc-dpop-client-browser/src/dpop/tokenExchange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/*
* Copyright 2020 Inrupt Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
* PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

import { IClient, IIssuerConfig } from "../common/types";
import { JSONWebKey } from "jose";
import { createDpopHeader, decodeJwt } from "./dpop";
import { generateJwkForDpop } from "./keyGeneration";
import formurlencoded from "form-urlencoded";

function hasAccessToken(
value: { access_token: string } | Record<string, unknown>
): value is { access_token: string } {
return value.access_token && typeof value.access_token === "string";
}

function hasIdToken(
value: { id_token: string } | Record<string, unknown>
): value is { id_token: string } {
return value.id_token && typeof value.id_token === "string";
}

function hasRefreshToken(
value: { refresh_token: string } | Record<string, unknown>
): value is { refresh_token: string } {
return value.refresh_token && typeof value.refresh_token === "string";
}

function hasTokenType(
value: { token_type: string } | Record<string, unknown>
): value is { token_type: string } {
return value.token_type && typeof value.token_type === "string";
}

export type TokenEndpointResponse = {
accessToken: string;
idToken: string;
webId: string;
refreshToken?: string;
dpopJwk?: string;
};

export type TokenEndpointInput = {
grantType: string;
redirectUri: string;
code: string;
codeVerifier: string;
};

type WebIdOidcIdToken = {
sub: string;
iss: string;
// The spec requires this capitalization of webid
webid?: string;
};

function isWebIdOidcIdToken(
token: WebIdOidcIdToken | Record<string, unknown>
): token is WebIdOidcIdToken {
return (
(token.sub &&
typeof token.sub === "string" &&
token.iss &&
typeof token.iss === "string" &&
!token.webid) ||
typeof token.webid === "string"
);
}

/**
* Extracts a WebId from an ID token based on https://github.com/solid/webid-oidc-spec.
* The upcoming spec is still a work in progress.
*
* Note: this function does not implement the userinfo WebId lookup yet.
* @param idToken
*/
async function deriveWebIdFromIdToken(idToken: string): Promise<string> {
const decoded = await decodeJwt(idToken);
if (!isWebIdOidcIdToken(decoded)) {
throw new Error(
`Invalid ID token: ${JSON.stringify(
decoded
)} is missing 'sub' or 'iss' claims`
);
}
if (decoded.webid) {
return decoded.webid;
}
if (!decoded.sub.match(/^https?:\/\/.+\..+$/)) {
throw new Error(
`Cannot extract WebID from ID token: the ID token returned by [${decoded.iss}] has no 'webid' claim, nor an IRI-like 'sub' claim: [${decoded.sub}]`
);
}
return decoded.sub;
}

function validatePreconditions(
issuer: IIssuerConfig,
data: TokenEndpointInput
): void {
if (
data.grantType &&
(!issuer.grantTypesSupported ||
!issuer.grantTypesSupported.includes(data.grantType))
) {
throw new Error(
`The issuer [${issuer.issuer.toString()}] does not support the [${
data.grantType
}] grant`
);
}
if (!issuer.tokenEndpoint) {
throw new Error(
`This issuer [${issuer.issuer.toString()}] does not have a token endpoint`
);
}
}

function validateTokenEndpointResponse(
tokenResponse: Record<string, unknown>,
dpop: boolean
): Record<string, unknown> & { access_token: string; id_token: string } {
if (!hasAccessToken(tokenResponse)) {
throw new Error(
`Invalid token endpoint response (missing the field 'access_token'): ${JSON.stringify(
tokenResponse
)}`
);
}

if (!hasIdToken(tokenResponse)) {
throw new Error(
`Invalid token endpoint response (missing the field 'id_token'): ${JSON.stringify(
tokenResponse
)}.`
);
}

if (!hasTokenType(tokenResponse)) {
throw new Error(
`Invalid token endpoint response (missing the field 'token_type'): ${JSON.stringify(
tokenResponse
)}`
);
}

if (dpop && tokenResponse.token_type.toLowerCase() !== "dpop") {
throw new Error(
`Invalid token endpoint response: requested a [DPoP] token, but got a 'token_type' value of [${tokenResponse.token_type}].`
);
}

if (!dpop && tokenResponse.token_type.toLowerCase() !== "bearer") {
throw new Error(
`Invalid token endpoint response: requested a [Bearer] token, but got a 'token_type' value of [${tokenResponse.token_type}].`
);
}
return tokenResponse;
}

export async function getTokens(
issuer: IIssuerConfig,
client: IClient,
data: TokenEndpointInput,
dpop: boolean
): Promise<TokenEndpointResponse | undefined> {
validatePreconditions(issuer, data);
const headers: Record<string, string> = {
"content-type": "application/x-www-form-urlencoded",
};
let dpopJwk: JSONWebKey | undefined = undefined;
if (dpop) {
dpopJwk = await generateJwkForDpop();
headers["DPoP"] = await createDpopHeader(
issuer.tokenEndpoint,
"POST",
dpopJwk
);
}
// TODO: is this necessary ? it's present in OAuth2 in action book, but not in spec
// if (client.clientSecret) {
// headers["Authorization"] = `Basic ${this.btoa(
// `${client.clientId}:${client.clientSecret}`
// }
const tokenRequestInit: RequestInit & {
headers: Record<string, string>;
} = {
method: "POST",
headers,
body: formurlencoded({
/* eslint-disable @typescript-eslint/camelcase */
grant_type: data.grantType,
redirect_uri: data.redirectUri,
code: data.code,
code_verifier: data.codeVerifier,
client_id: client.clientId,
/* eslint-enable @typescript-eslint/camelcase */
}),
};

const rawTokenResponse = (await (
await fetch(issuer.tokenEndpoint.toString(), tokenRequestInit)
).json()) as Record<string, unknown>;

const tokenResponse = validateTokenEndpointResponse(rawTokenResponse, dpop);
const webId = await deriveWebIdFromIdToken(tokenResponse.id_token);

return {
accessToken: tokenResponse.access_token,
idToken: tokenResponse.id_token,
refreshToken: hasRefreshToken(tokenResponse)
? tokenResponse.refresh_token
: undefined,
webId: webId,
dpopJwk: dpopJwk ? JSON.stringify(dpopJwk) : undefined,
};
}
13 changes: 11 additions & 2 deletions packages/oidc-dpop-client-browser/src/index.ts
Original file line number Diff line number Diff line change
@@ -41,10 +41,19 @@ export {

export { registerClient } from "./dcr/clientRegistrar";
export {
generateJwkForDpop,
generateJwkRsa,
decodeJwt,
signJwt,
createDpopHeader,
privateJwkToPublicJwk,
} from "./dpop/dpop";
export { generateJwkForDpop, generateJwkRsa } from "./dpop/keyGeneration";
export {
getTokens,
TokenEndpointInput,
TokenEndpointResponse,
} from "./dpop/tokenExchange";
export {
IClient,
IClientRegistrarOptions,
IIssuerConfig,
} from "./common/types";

0 comments on commit 4dd0ef5

Please sign in to comment.