Skip to content

Commit

Permalink
Merge pull request #314 from axa-group/tanettrimas/pkce
Browse files Browse the repository at this point in the history
Add PKCE support
  • Loading branch information
poveden authored Nov 21, 2024
2 parents 442cdb6 + ea7e8ae commit 4ed5353
Show file tree
Hide file tree
Showing 7 changed files with 392 additions and 12 deletions.
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ request.get(
);
```

### Supported grant types

- No authentication
- Client Credentials grant
- Resource Owner Password Credentials grant
- Authorization Code grant, with Proof Key for Code Exchange (PKCE) support
- Refresh token grant

### Supported JWK formats

| Algorithm | kty | alg |
Expand Down Expand Up @@ -220,13 +228,7 @@ Returns the JSON Web Key Set (JWKS) of all the keys configured in the server.

### POST `/token`

Issues access tokens. Currently, this endpoint is limited to:

- No authentication
- Client Credentials grant
- Resource Owner Password Credentials grant
- Authorization code grant
- Refresh token grant
Issues access tokens.

### GET `/authorize`

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
"lint": "eslint --cache --cache-location .cache/ --ext=.ts src test --max-warnings 0",
"prepack": "yarn build --tsBuildInfoFile null --incremental false",
"pretest": "yarn lint",
"test": "yarn vitest --run --coverage"
"test": "yarn vitest --run --coverage",
"test:watch": "yarn vitest --watch"
},
"dependencies": {
"basic-auth": "^2.0.1",
Expand Down
51 changes: 50 additions & 1 deletion src/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { readFileSync } from 'fs';

import { isPlainObject } from 'is-plain-object';

import type { TokenRequest } from './types';
import type { CodeChallenge, PKCEAlgorithm, TokenRequest } from './types';
import { webcrypto as crypto } from 'crypto';

export const defaultTokenTtl = 3600;

Expand Down Expand Up @@ -60,6 +61,17 @@ export function assertIsPlainObject(
}
}

export async function pkceVerifierMatchesChallenge(
verifier: string,
challenge: CodeChallenge,
) {
const generatedChallenge = await createPKCECodeChallenge(
verifier,
challenge.method,
);
return generatedChallenge === challenge.challenge;
}

export function assertIsValidTokenRequest(
body: unknown,
): asserts body is TokenRequest {
Expand Down Expand Up @@ -111,3 +123,40 @@ export const readJsonFromFile = (filepath: string): Record<string, unknown> => {

return maybeJson;
};

export const isValidPkceCodeVerifier = (verifier: string) => {
const PKCE_CHALLENGE_REGEX = /^[A-Za-z0-9\-._~]{43,128}$/;
return PKCE_CHALLENGE_REGEX.test(verifier);
};

export const createPKCEVerifier = () => {
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
return Buffer.from(randomBytes).toString('base64url');
};

export const supportedPkceAlgorithms = ['plain', 'S256'] as const;

export const createPKCECodeChallenge = async (
verifier: string = createPKCEVerifier(),
algorithm: PKCEAlgorithm = 'plain',
) => {
let challenge: string;

switch (algorithm) {
case 'plain': {
challenge = verifier;
break;
}
case 'S256': {
const buffer = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier),
);
challenge = Buffer.from(buffer).toString('base64url');
break;
}
default:
throw new Error(`Unsupported PKCE method ("${algorithm as string}")`);
}
return challenge;
};
72 changes: 72 additions & 0 deletions src/lib/oauth2-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,25 @@ import {
assertIsStringOrUndefined,
assertIsValidTokenRequest,
defaultTokenTtl,
isValidPkceCodeVerifier,
pkceVerifierMatchesChallenge,
supportedPkceAlgorithms,
} from './helpers';
import type {
CodeChallenge,
JwtTransform,
MutableRedirectUri,
MutableResponse,
MutableToken,
OAuth2Endpoints,
OAuth2EndpointsInput,
PKCEAlgorithm,
ScopesOrTransform,
StatusCodeMutableResponse,
} from './types';
import { Events } from './types';
import { InternalEvents } from './types-internals';
import { AssertionError } from 'assert';

const DEFAULT_ENDPOINTS: OAuth2Endpoints = Object.freeze({
wellKnownDocument: '/.well-known/openid-configuration',
Expand All @@ -71,6 +77,7 @@ export class OAuth2Service extends EventEmitter {
#issuer: OAuth2Issuer;
#requestHandler: RequestListener;
#nonce: Record<string, string>;
#codeChallenges: Map<string, CodeChallenge>;
#endpoints: OAuth2Endpoints;

constructor(oauth2Issuer: OAuth2Issuer, endpoints?: OAuth2EndpointsInput) {
Expand All @@ -80,6 +87,7 @@ export class OAuth2Service extends EventEmitter {
this.#endpoints = { ...DEFAULT_ENDPOINTS, ...endpoints };
this.#requestHandler = this.buildRequestHandler();
this.#nonce = {};
this.#codeChallenges = new Map();
}

/**
Expand Down Expand Up @@ -169,6 +177,7 @@ export class OAuth2Service extends EventEmitter {
subject_types_supported: ['public'],
end_session_endpoint: `${this.issuer.url}${this.#endpoints.endSession}`,
introspection_endpoint: `${this.issuer.url}${this.#endpoints.introspect}`,
code_challenge_methods_supported: supportedPkceAlgorithms,
};

return res.json(openidConfig);
Expand All @@ -190,6 +199,39 @@ export class OAuth2Service extends EventEmitter {
let xfn: ScopesOrTransform | undefined;

assertIsValidTokenRequest(req.body);

if ('code_verifier' in req.body && 'code' in req.body) {
try {
const code = req.body.code;
const verifier = req.body['code_verifier'];
const savedCodeChallenge = this.#codeChallenges.get(code);
if (savedCodeChallenge === undefined) {
throw new AssertionError({
message: 'code_challenge required',
});
}
this.#codeChallenges.delete(code);
if (!isValidPkceCodeVerifier(verifier)) {
throw new AssertionError({
message:
"Invalid 'code_verifier'. The verifier does not conform with the RFC7636 spec. Ref: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1",
});
}
const doesVerifierMatchCodeChallenge =
await pkceVerifierMatchesChallenge(verifier, savedCodeChallenge);
if (!doesVerifierMatchCodeChallenge) {
throw new AssertionError({
message: 'code_verifier provided does not match code_challenge',
});
}
} catch (e) {
return res.status(400).json({
error: 'invalid_request',
error_description: (e as AssertionError).message,
});
}
}

const reqBody = req.body;

let { scope } = reqBody;
Expand Down Expand Up @@ -294,16 +336,46 @@ export class OAuth2Service extends EventEmitter {
redirect_uri: redirectUri,
response_type: responseType,
state,
code_challenge,
code_challenge_method,
} = req.query;

assertIsString(redirectUri, 'Invalid redirectUri type');
assertIsStringOrUndefined(nonce, 'Invalid nonce type');
assertIsStringOrUndefined(scope, 'Invalid scope type');
assertIsStringOrUndefined(state, 'Invalid state type');
assertIsStringOrUndefined(code_challenge, 'Invalid code_challenge type');
assertIsStringOrUndefined(
code_challenge_method,
'Invalid code_challenge_method type',
);

const url = new URL(redirectUri);

if (responseType === 'code') {
if (code_challenge) {
const codeChallengeMethod = code_challenge_method ?? 'plain';
assertIsString(
codeChallengeMethod,
"Invalid 'code_challenge_method' type",
);
if (
!supportedPkceAlgorithms.includes(
codeChallengeMethod as PKCEAlgorithm,
)
) {
return res.status(400).json({
error: 'invalid_request',
error_description: `Unsupported code_challenge method ${codeChallengeMethod}. The following code_challenge_method are supported: ${supportedPkceAlgorithms.join(
', ',
)}`,
});
}
this.#codeChallenges.set(code, {
challenge: code_challenge,
method: codeChallengeMethod as PKCEAlgorithm,
});
}
if (nonce !== undefined) {
this.#nonce[code] = nonce;
}
Expand Down
9 changes: 9 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ServerOptions } from 'https';
import { JWKWithKid } from './types-internals';
import { supportedPkceAlgorithms } from './helpers';

export interface TokenRequest {
scope?: string;
Expand All @@ -8,6 +9,7 @@ export interface TokenRequest {
client_id?: unknown;
code?: string;
aud?: string[] | string;
code_verifier?: string;
}

export interface Options {
Expand Down Expand Up @@ -106,3 +108,10 @@ export type OAuth2EndpointsInput = Partial<OAuth2Endpoints>;
export interface OAuth2Options {
endpoints?: OAuth2EndpointsInput;
}

export type PKCEAlgorithm = (typeof supportedPkceAlgorithms)[number];

export interface CodeChallenge {
challenge: string;
method: PKCEAlgorithm;
}
61 changes: 60 additions & 1 deletion test/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import type { AddressInfo } from 'net';

import {
Expand All @@ -7,8 +7,13 @@ import {
assertIsString,
assertIsStringOrUndefined,
assertIsValidTokenRequest,
createPKCECodeChallenge,
createPKCEVerifier,
isValidPkceCodeVerifier,
pkceVerifierMatchesChallenge,
shift,
} from '../src/lib/helpers';
import { CodeChallenge, PKCEAlgorithm } from '../src';

describe('helpers', () => {
describe('assertIsString', () => {
Expand Down Expand Up @@ -127,4 +132,58 @@ describe('helpers', () => {
expect(() => shift(["a"])).not.toThrow();
});
});

describe('pkce', () => {
describe('code_verifier', () => {
it('should accept a valid PKCE code_verifier', () => {
const verifier128 =
'PXa7p8YHHUAJGrcG2eW0x7FY_EBtRTlaUHnyz1jKWnNp0G-2HZt9KjA0UOp87DmuIqoV4Y_owVsM-QICvrSa5dWxOndVEhSsFMMgy68AYkw4PGHkGaN_aIRIHJ8mQ4EZ';
const verifier42 = 'xyo94uhy3zKvgB0NJwLms86SwcjtWviEOpkBnGgaLlo';
expect(isValidPkceCodeVerifier(verifier128)).toBe(true);
expect(isValidPkceCodeVerifier(verifier42)).toBe(true);

const verifierWith129chars = `${verifier128}a`;
expect(isValidPkceCodeVerifier(verifierWith129chars)).toBe(false);
expect(
isValidPkceCodeVerifier(verifier42.slice(0, verifier42.length - 1))
).toBe(false);
});

it('should create a valid code_verifier', () => {
expect(isValidPkceCodeVerifier(createPKCEVerifier())).toBe(true);
});

it('should create a valid code_challenge', async () => {
const verifier = 'xyo94uhy3zKvgB0NJwLms86SwcjtWviEOpkBnGgaLlo';
const expectedChallenge = 'b7elB7ZyxIXgFyvBznKvxl7wOB-H17Pz0a3B62NIMFI';
const generatedCodeChallenge = await createPKCECodeChallenge(
verifier,
'S256'
);
expect(generatedCodeChallenge).toBe(expectedChallenge);
const expectedCodeLength = 43; // BASE64-urlencoded sha256 hashes should always be 43 characters in length.
expect(
await createPKCECodeChallenge(createPKCEVerifier(), 'S256')
).toHaveLength(expectedCodeLength);
});

it('should match code_verifier and code_challenge', async () => {
const verifier = createPKCEVerifier();
const codeChallengeMethod = 'S256';
const challenge: CodeChallenge = {
challenge: await createPKCECodeChallenge(
verifier,
codeChallengeMethod
),
method: codeChallengeMethod,
};
expect(await pkceVerifierMatchesChallenge(verifier, challenge)).toBe(true);
});

it('should throw on an unsupported method', async () => {
const verifier = createPKCEVerifier();
await expect(createPKCECodeChallenge(verifier, 'BAD-METHOD' as PKCEAlgorithm)).rejects.toThrowError('Unsupported PKCE method ("BAD-METHOD")');
});
});
});
});
Loading

0 comments on commit 4ed5353

Please sign in to comment.