Skip to content

Commit

Permalink
feat: support EC private keys for OAuth service apps
Browse files Browse the repository at this point in the history
OKTA-496997
<<<Jenkins Check-In of Tested SHA: 2114435 for [email protected]>>>
Artifact: okta-sdk-nodejs
Files changed count: 11
PR Link: #329
  • Loading branch information
oleksandrpravosudko-okta authored and eng-prod-CI-bot-okta committed May 31, 2022
1 parent ff38671 commit fd0462d
Show file tree
Hide file tree
Showing 11 changed files with 114 additions and 25 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

# 6.5.0

### Features

- [#329](https://github.com/okta/okta-sdk-nodejs/pull/329) Supports EC private keys for OAuth2 service applications

### Other

- [#323](https://github.com/okta/okta-sdk-nodejs/pull/323) Adds `userAgent` property to configurtation type and documents its usage
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const client = new okta.Client({
clientId: '{oauth application ID}',
scopes: ['okta.users.manage'],
privateKey: '{JWK}' // <-- see notes below
keyId: 'kidValue'
});
```

Expand All @@ -106,7 +107,7 @@ The `privateKey` can be passed in the following ways:
- A string in PEM format
- As a JSON object, in JWK format

> Note: in case OAuth client app uses multiple JWKs, `privateKey` should specify `kid` attribute.
> Note: in case OAuth client app uses multiple JWKs, `privateKey` should specify `kid` attribute. When `privateKey` is passed in PEM format, `keyId` value should be provided in SDK configuation.

## Examples
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"repository": "https://github.com/okta/okta-sdk-nodejs",
"dependencies": {
"deep-copy": "^1.4.2",
"eckles": "^1.4.1",
"form-data": "^4.0.0",
"https-proxy-agent": "^5.0.0",
"js-yaml": "^4.1.0",
Expand Down
1 change: 1 addition & 0 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class Client extends GeneratedApiClient {
this.clientId = parsedConfig.client.clientId;
this.scopes = parsedConfig.client.scopes.split(' ');
this.privateKey = parsedConfig.client.privateKey;
this.keyId = parsedConfig.client.keyId;
this.oauth = new Oauth(this);
}

Expand Down
3 changes: 2 additions & 1 deletion src/config-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ class ConfigLoader {
token: '',
clientId: '',
scopes: '',
privateKey: ''
privateKey: '',
keyId: '',
}
};
}
Expand Down
38 changes: 31 additions & 7 deletions src/jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@

const nJwt = require('njwt');
const Rasha = require('rasha');
const DEFAULT_ALG = 'RS256';
const Eckles = require('eckles');
const DEFAULT_RSA_ALG = 'RS256';
const DEFAULT_EC_ALG = 'ES256';

function getPemAndJwk(privateKey) {
let jwk;
Expand All @@ -33,14 +35,35 @@ function getPemAndJwk(privateKey) {
}

if (jwk) {
return Rasha.export({ jwk }).then(function (pem) {
// PEM in PKCS1 (traditional) format
let keyParsingLib;
let defaultAlgo;
const options = {};
if (jwk.kty === 'EC') {
keyParsingLib = Eckles;
defaultAlgo = DEFAULT_EC_ALG;
options.format = 'pkcs8';
} else if (jwk.kty === 'RSA') {
keyParsingLib = Rasha;
defaultAlgo = DEFAULT_RSA_ALG;
} else {
return Promise.reject(new Error(`Key type ${jwk.kty} is not supported.`));
}
return keyParsingLib.export({ jwk, ...options }).then(function (pem) {
// PEM in PKCS1 (traditional) format for RSA keys and PKCS8 for EC keys
jwk.alg = jwk.alg || defaultAlgo;
return { pem, jwk };
});
} else {
return Rasha.import({ pem }).then(function (jwk) {
jwk.alg = jwk.alg || DEFAULT_ALG;
jwk.alg = jwk.alg || DEFAULT_RSA_ALG;
return { pem, jwk };
}).catch(function (rsaError) {
return Eckles.import({ pem }).then(function (jwk) {
jwk.alg = jwk.alg || DEFAULT_EC_ALG;
return { pem, jwk };
}).catch(function (ecError) {
throw new Error(`Unable to convert private key from PEM to JWK: ${rsaError.message}, ${ecError.message}`);
});
});
}
}
Expand All @@ -55,14 +78,15 @@ function makeJwt(client, endpoint) {
return getPemAndJwk(client.privateKey)
.then(res => {
const { pem, jwk } = res;
const alg = jwk.alg || DEFAULT_ALG;
const alg = jwk.alg;
let jwt = nJwt.create(claims, pem, alg)
.setIssuedAt(now)
.setExpiration(plus5Minutes)
.setIssuer(client.clientId)
.setSubject(client.clientId);
if (jwk.kid) {
jwt = jwt.setHeader('kid', jwk.kid);
const kid = jwk.kid || client.keyId;
if (kid) {
jwt = jwt.setHeader('kid', kid);
}
// JWT object is returned. It needs to be compacted with jwt.compact() before it can be used
return jwt;
Expand Down
2 changes: 2 additions & 0 deletions src/types/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export declare class Client extends ParameterizedOperationsClient {
requestExecutor?: RequestExecutor,
authorizationMode?: string,
privateKey?: string | Record<string, unknown>
keyId?: string;
cacheStore?: CacheStorage,
cacheMiddleware?: typeof defaultCacheMiddleware | unknown
defaultCacheMiddlewareResponseBufferSize?: number,
Expand All @@ -41,6 +42,7 @@ export declare class Client extends ParameterizedOperationsClient {
clientId: string;
scopes: string[];
privateKey: string;
keyId: string;
oauth: OAuth;
http: Http;
}
28 changes: 26 additions & 2 deletions test/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ TSuuiZ8CgYEAyCXs6/IIwf3cyXQ45iEuOPHi3uY1jPGoJPZk1p4ZOzNsS3OQBFqb
diTDByp4vF0qGVwDRaHF0nXE1CRw0gLeEz1dAB6+2MPFK0EUC5ko6Z4=
-----END RSA PRIVATE KEY-----`;

const EC_PEM = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgrYhWdq/JkPbEyAyI
mEJtcnvsNDbj62cS+ej0gknNI1ShRANCAATmrCLEisgrNoavcGXB7ih2La9vfH8J
Xp2fqN0FcthAfsOPyHHkaCJMYTNYmYbbbUQzF2p5q+IaqUvh4c/FCY27
-----END PRIVATE KEY-----`;

const INVALID_PEM = `-----BEGIN RSA PRIVATE KEY-----
IAMGARBAGE
-----END RSA PRIVATE KEY-----`;
Expand All @@ -43,11 +49,29 @@ const JWK = {
n: 'u7p0yhCzkbK5gF_WleqnpI1wRX5-B-j4DIY-aSDcnDYENIIzf4Kc4DX7bTxVvApx5KlFqj3D6rMk92oZTeiTzz-Tz22gEeubSLhMPq_TuvMYKUySGGctK9yJ1VIvUxT8ZMo48KX_iD_6TGiOcHtgx9jHLQxfNAr12kG-w8Yc1zUiJtEXxJsRtwOGDmGTB4IUtpepbyKKR3E3tiqTqHjzfoTaIh0VcuOijp9P5BlZH8e2z5ABTcmlgwuSegARC7iDbmuVTRXro1eZQ2suGFN-dcs0iPkpHB6KzSrMU_-5zWFwV6vSCEtVNONqijjSnjO6_bDOZey0z70cCYWHqNKj8w'
};

const INVALID_JWK = {};
const EC_JWK = {
kty: 'EC',
crv: 'P-256',
d: 'rYhWdq_JkPbEyAyImEJtcnvsNDbj62cS-ej0gknNI1Q',
x: '5qwixIrIKzaGr3Blwe4odi2vb3x_CV6dn6jdBXLYQH4',
y: 'w4_IceRoIkxhM1iZhtttRDMXanmr4hqpS-Hhz8UJjbs'
};


const UNKWNOWN_KEY_TYPE_JWK = {
kty: 'OKP'
};
const INVALID_RSA_JWK = {
kty: 'RSA'
};


module.exports = {
PEM,
EC_PEM,
INVALID_PEM,
JWK,
INVALID_JWK
EC_JWK,
UNKWNOWN_KEY_TYPE_JWK,
INVALID_RSA_JWK
};
31 changes: 25 additions & 6 deletions test/jest/jwt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@ const nJwt = require('njwt');
const Rasha = require('rasha');
const JWT = require('../../src/jwt');
const { advanceTo } = require('jest-date-mock');
const { PEM, INVALID_PEM, JWK, INVALID_JWK } = require('../constants');
const { PEM, EC_PEM, INVALID_PEM, JWK, EC_JWK, UNKWNOWN_KEY_TYPE_JWK, INVALID_RSA_JWK } = require('../constants');

describe('JWT', () => {
describe('getPemAndJwk', () => {
function validateResult(res) {
expect(res.pem).toBe(PEM);
expect(res.jwk).toEqual(JWK);
function validateResult(res, keyType) {
if (keyType === 'EC') {
expect(res.pem).toBe(EC_PEM);
expect(res.jwk).toEqual(EC_JWK);
} else {
expect(res.pem).toBe(PEM);
expect(res.jwk).toEqual(JWK);
}
}
// eslint-disable-next-line jest/expect-expect
it('can produce a JWK from a PEM', () => {
Expand All @@ -28,13 +33,27 @@ describe('JWT', () => {
return JWT.getPemAndJwk(privateKey)
.then(res => validateResult(res));
});
it('accepts EC JWKs', () => {
const privateKey = EC_JWK;
return JWT.getPemAndJwk(privateKey)
.then(res => validateResult(res, 'EC'));
});
it('accepts EC PEM', () => {
const privateKey = EC_PEM;
return JWT.getPemAndJwk(privateKey)
.then(res => validateResult(res, 'EC'));
});
it('Throws if invalid PEM is passed', () => {
const privateKey = INVALID_PEM;
return expect(JWT.getPemAndJwk(privateKey)).rejects.toThrow('not an RSA PKCS#8 public or private key (wrong format)');
});
it('Throws if JWK with unknown key type is passed', () => {
const privateKey = JSON.stringify(UNKWNOWN_KEY_TYPE_JWK);
return expect(JWT.getPemAndJwk(privateKey)).rejects.toThrow('Key type OKP is not supported.');
});
it('Throws if invalid JWK is passed', () => {
const privateKey = JSON.stringify(INVALID_JWK);
return expect(JWT.getPemAndJwk(privateKey)).rejects.toThrow('options.jwk.kty must be \'RSA\' for RSA keys');
const privateKey = JSON.stringify(INVALID_RSA_JWK);
return expect(JWT.getPemAndJwk(privateKey)).rejects.toThrow('The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received undefined');
});
});
describe('makeJwt', () => {
Expand Down
23 changes: 15 additions & 8 deletions test/unit/config-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ describe('ConfigLoader', () => {
token: '',
clientId: '',
scopes: '',
privateKey: ''
privateKey: '',
keyId: '',
}
});
});
Expand Down Expand Up @@ -52,7 +53,8 @@ describe('ConfigLoader', () => {
token: '',
clientId: '',
scopes: '',
privateKey: ''
privateKey: '',
keyId: '',
}
});
sinon.assert.calledOnce(loader.applyEnvVars);
Expand All @@ -76,7 +78,8 @@ describe('ConfigLoader', () => {
authorizationMode: 'PrivateKey',
clientId: '',
scopes: '',
privateKey: ''
privateKey: '',
keyId: '',
}
});
sinon.assert.calledOnce(loader.applyEnvVars);
Expand All @@ -99,7 +102,8 @@ describe('ConfigLoader', () => {
token: '',
clientId: '',
scopes: '',
privateKey: ''
privateKey: '',
keyId: '',
}
});
sinon.assert.calledOnce(loader.applyEnvVars);
Expand Down Expand Up @@ -129,7 +133,8 @@ describe('ConfigLoader', () => {
authorizationMode: '',
clientId: '',
scopes: '',
privateKey: ''
privateKey: '',
keyId: '',
}
});
assert.equal(loader.config.client.orgUrl, 'barbaz');
Expand All @@ -145,7 +150,8 @@ describe('ConfigLoader', () => {
authorizationMode: 'SSWS',
clientId: '',
scopes: '',
privateKey: ''
privateKey: '',
keyId: '',
}
};
const b = {
Expand All @@ -161,7 +167,8 @@ describe('ConfigLoader', () => {
authorizationMode: 'PrivateKey',
clientId: '',
scopes: '',
privateKey: ''
privateKey: '',
keyId: '',
}
};
var loader = new ConfigLoader();
Expand All @@ -188,7 +195,7 @@ describe('ConfigLoader', () => {
};
const a = {
client: {
privateKey
privateKey,
}
};
var loader = new ConfigLoader();
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,11 @@ ecdsa-sig-formatter@^1.0.5:
dependencies:
safe-buffer "^5.0.1"

eckles@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/eckles/-/eckles-1.4.1.tgz#5e97fefa8554a7af594070c461e6b25fe3819382"
integrity sha512-auWyk/k8oSkVHaD4RxkPadKsLUcIwKgr/h8F7UZEueFDBO7BsE4y+H6IMUDbfqKIFPg/9MxV6KcBdJCmVVcxSA==

electron-to-chromium@^1.4.118:
version "1.4.137"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.137.tgz#186180a45617283f1c012284458510cd99d6787f"
Expand Down

0 comments on commit fd0462d

Please sign in to comment.