Skip to content

Commit

Permalink
feat: DPoP verification in token request
Browse files Browse the repository at this point in the history
  • Loading branch information
RebeccaSelvaggini committed Feb 20, 2024
1 parent 289eb19 commit a394032
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 45 deletions.
37 changes: 27 additions & 10 deletions pkg/oauth/src/authenticateHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class AuthenticateHandler {
if (options.scope && undefined === options.addAuthorizedScopesHeader) {
throw new InvalidArgumentError('Missing parameter: `addAuthorizedScopesHeader`');
}

// console.log(options.scope && !options.model.verifyScope) = undefined but pass the check
if (options.scope && !options.model.verifyScope) {
throw new InvalidArgumentError('Invalid argument: model does not implement `verifyScope()`');
}
Expand Down Expand Up @@ -61,6 +61,12 @@ export class AuthenticateHandler {
throw new Error("Invalid Client");
}

const scope = request.body.scope;
const resource = request.body.resource;
if (!this.verifyScope(scope, resource)){
throw new Error("Given scope is not valid");
}

const url = "https://did.dyne.org/dids/" + cl_id;

const response = await fetch(url, {method: 'GET'});
Expand Down Expand Up @@ -90,7 +96,7 @@ export class AuthenticateHandler {
if (!outVerify) {
return undefined;
}
return client["clientSecret"];
return client["id"];

} catch (e) {
// Include the "WWW-Authenticate" response header field if the client
Expand Down Expand Up @@ -153,14 +159,25 @@ export class AuthenticateHandler {
* Verify scope.
*/

async verifyScope(accessToken: Token) {
if (this.model.verifyScope && this.scope) {
const scope = await this.model.verifyScope(accessToken, this.scope);

if (!scope) {
throw new InsufficientScopeError('Insufficient scope: authorized scope is insufficient');
}
}
async verifyScope(scope:string[], resource:string) {
// see https://openid.github.io/OpenID4VCI/openid-4-verifiable-credential-issuance-wg-draft.html#section-5.1.2
if (!scope) {
throw new InsufficientScopeError('Insufficient scope: authorized scope is insufficient');
}
if(!resource) {
throw new Error('Invalid request: needed resource to verify scope');
}
//TODO: this should access the /.well-known/openid-credential-issuer
// and verify that the string in scope is one of the credential_configuration_id
// NOTE that this should also handle the case of multiple scope values
// const url = resource + '/.well-known/openid-credential-issuer';
// const response = await fetch(url, {method: 'GET'});
// if (!response.ok) {
// throw new Error(`Error! status: ${response.status}`);
// }

// const result = await response.json();
return true;
}

/**
Expand Down
70 changes: 39 additions & 31 deletions pkg/oauth/src/model.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AuthorizationCodeModel, Client, User, Token, Falsey, AuthorizationCode } from "@node-oauth/oauth2-server";
import { SignJWT, jwtVerify, generateKeyPair, JWK, importJWK } from 'jose';
import { AuthorizationCodeModel, Client, User, Token, Falsey, AuthorizationCode, Request } from "@node-oauth/oauth2-server";
import { SignJWT, jwtVerify, generateKeyPair, JWK, importJWK, decodeProtectedHeader, decodeJwt } from 'jose';
import { randomBytes } from 'crypto';

export class InMemoryCache implements AuthorizationCodeModel {
Expand Down Expand Up @@ -228,32 +228,11 @@ export class InMemoryCache implements AuthorizationCodeModel {
return Promise.resolve(tokenSaved);
};

/**
* Invoked to check if the requested scope is valid for a particular client/user combination.
*
*/

validateScope(user: User, client: Client, scope?: string[] | undefined): Promise<string[] | Falsey> {
// see https://openid.github.io/OpenID4VCI/openid-4-verifiable-credential-issuance-wg-draft.html#section-5.1.2
//TODO: this should access the /.well-known/openid-credential-issuer
// and verify that the string in scope is one of the credential_configuration_id
if (user && client && scope)
return Promise.resolve(scope);
return Promise.resolve(undefined);
};

/**
* Generate access token.
*/

async generateAccessToken(client: Client, user: User, scope?: string[]): Promise<string> {

if (scope) {
const validatedScope = await this.validateScope(user, client, scope);
if (!validatedScope) {
throw new Error('Given scope is not valid for this client/user combination');
}
}
async generateAccessToken(client: Client): Promise<string> {

const clientId = client.id;
if (this.jwk != null)
Expand All @@ -276,13 +255,7 @@ export class InMemoryCache implements AuthorizationCodeModel {
/**
* Generate authorization code.
*/
async generateAuthorizationCode?(client: Client, user: User, scope: string[]): Promise<string> {
if (scope) {
const validatedScope = await this.validateScope(user, client, scope);
if (!validatedScope) {
throw new Error('Given scope is not valid for this client/user combination');
}
}
async generateAuthorizationCode(client: Client): Promise<string> {

const clientId = client.id;
if (this.jwk != null)
Expand All @@ -303,6 +276,41 @@ export class InMemoryCache implements AuthorizationCodeModel {
return authCode;
}

// For reference see Section 4.3 of https://datatracker.ietf.org/doc/html/rfc9449.html
async verifyDpopProof(dpop:string, request: Request){

const header = decodeProtectedHeader(dpop);

if(!header.typ) throw Error("Invalid DPoP: missing typ header parameter");
if(header.typ !== "dpop+jwt") throw Error("Invalid DPoP: typ must be dpop+jwt");

if(!header.alg) throw Error("Invalid DPoP: missing alg header parameter");
if(header.alg !== 'ES256') throw Error("Invalid DPoP: alg must be ES256");

if(!header.jwk) throw Error("Invalid DPoP: missing jwk header parameter");
// Missing check: The jwk JOSE Header Parameter does not contain a private key.
const publicKey = await importJWK(header.jwk);
const verify_sig = await jwtVerify(dpop, publicKey);
if(!verify_sig){
throw Error("Invalid DPoP: invalid signature");
}

const payload = decodeJwt(dpop);

if(!payload.iat) throw Error("Invalid DPoP: missing iat payload parameter");
var FIVE_MIN=5*60*1000;
var date = new Date().getTime();
if((date - payload.iat) > FIVE_MIN) throw Error("Invalid DPoP: expired");

if(!payload.jti) throw Error("Invalid DPoP: missing jti payload parameter");

if(!payload['htm']) throw Error("Invalid DPoP: missing htm payload parameter");
if(payload['htm'] !== request.method) throw Error("Invalid DPoP: htm does not match request method");

if(!payload['htu']) throw Error("Invalid DPoP: missing htu payload parameter");
// Missing check: The htu claim matches the HTTP URI value for the HTTP request in which the JWT was received, ignoring any query and fragment parts.
return true;
}
}

// generateRefreshToken?(client: Client, user: User, scope: string[]): Promise<string> {
Expand Down
8 changes: 7 additions & 1 deletion pkg/oauth/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,13 @@ export const createToken = p.new(
if(!code){
throw Error("Authorization Code is not valid");
}

// this checks should be done inside server.token()?
var dpop = request.headers!['dpop'];
if(dpop){
var check = await model.verifyDpopProof(dpop, request);
if(!check) throw Error("Invalid request: DPoP header parameter is not valid");
}
//------------------
return ctx.pass(await server.token(request, response, options));
}
);
Expand Down
45 changes: 42 additions & 3 deletions pkg/oauth/test/e2e.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,41 @@
import test from 'ava';
import { Slangroom } from '@slangroom/core';
import { oauth } from '@slangroom/oauth';
import { SignJWT, importJWK } from 'jose';
import { randomBytes } from 'crypto';

// For reference see Section 4 of https://datatracker.ietf.org/doc/html/rfc9449.html
async function create_dpop_proof(){
//this is done client side for token request
// here for now, just for testing
var private_jwk = {
"kty":"EC",
"x":"iyuaHgjseiWTdKd_EuhxO43oayK05z_wEb2SlsxofSo",
"y":"EJBrgZE_wqm3P0bPuuYpO-5wbEbk9xy-8hdOiVODjOM",
"d": "neBDuFx9xMkXWpoU+Tk9KAofgH3qzN0e3jSSjssrM8U=",
"crv":"P-256"
}

var privateKey = await importJWK(private_jwk);


const dpop = new SignJWT({ jti: randomBytes(16).toString('base64url'),
htm:"POST",
htu:"https://server.example.com/token", })
.setProtectedHeader({
typ:"dpop+jwt",
alg:"ES256",
jwk: {
kty:"EC",
x:"iyuaHgjseiWTdKd_EuhxO43oayK05z_wEb2SlsxofSo",
y:"EJBrgZE_wqm3P0bPuuYpO-5wbEbk9xy-8hdOiVODjOM",
crv:"P-256"
}
})
.setIssuedAt(Date.now())
.sign(privateKey);
return dpop;
}

//for details on code_challenge/code_verifier see https://node-oauthoauth2-server.readthedocs.io/en/master/misc/pkce.html#authorization-request

Expand All @@ -25,7 +60,7 @@ Then print data
"y": "lf0u0pMj4lGAzZix5u4Cm5CMQIgMNpkwy163wtKYVKI",
"d": "0g5vAEKzugrXaRbgKG0Tj2qJ5lMP4Bezds1_sTybkfk"
},
body: "response_type=code&client_id=did:dyne:sandbox.genericissuer:6Cp8mPUvJmQaMxQPSnNyhb74f9Ga4WqfXCkBneFgikm5&state=xyz&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&scope=UniversityDegreeCredential&redirect_uri=https%3A%2F%2FWallet.example.org%2Fcb",
body: "response_type=code&client_id=did:dyne:sandbox.genericissuer:6Cp8mPUvJmQaMxQPSnNyhb74f9Ga4WqfXCkBneFgikm5&state=xyz&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&scope=UniversityDegreeCredential&resource=https%3A%2F%2Fcredential-issuer.example.com&redirect_uri=https%3A%2F%2FWallet.example.org%2Fcb",
headers: {
"Authorization": ""
},
Expand Down Expand Up @@ -81,13 +116,17 @@ Then print data
authCode: res.result['authCode_jwt'] || {},
body: res2.result['body'] || '',
headers: {
"Authorization": "",
"content-length": 42,
"Content-Type": "application/x-www-form-urlencoded"
"Content-Type": "application/x-www-form-urlencoded",
"DPoP": await create_dpop_proof()
},

},
});
console.log(res3.result['accessToken_jwt']);
t.truthy(res3.result['accessToken_jwt']);
});

//authorization_details=%5B%7B%22type%22%3A%20%22openid_credential%22%2C%20%22credential_configuration_id%22%3A%20%22UniversityDegreeCredential%22%7D%5D
// scope=%5B%7B%22resource%22%3A%20%22https%3A%2F%2Fcredential-issuer.example.com%22%2C%20%22credential_configuration_id%22%3A%20%22UniversityDegreeCredential%22%7D%5D
//&resource=https%3A%2F%2Fcredential-issuer.example.com

0 comments on commit a394032

Please sign in to comment.