Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved OIDC compliance #243

Merged
merged 15 commits into from
Oct 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .jsdoc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"source": {
"include": ["src", "index.js", "package.json", "README.md"],
"includePattern": ".js$",
"excludePattern": "(node_modules/|docs|__tests__)"
"excludePattern": "(node_modules/|docs|__tests__|src/jwt)"
},
"plugins": ["./node_modules/jsdoc/plugins/markdown"],
"templates": {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
},
"dependencies": {
"base-64": "^0.1.0",
"jsrsasign": "8.0.12",
"jwt-decode": "^2.2.0",
"url": "^0.11.0"
},
"jest": {
Expand Down
696 changes: 696 additions & 0 deletions src/jwt/__tests__/jwt.spec.js

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/jwt/__tests__/pubkey.pem
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuGbXWiK3dQTyCbX5xdE4
yCuYp0AF2d15Qq1JSXT/lx8CEcXb9RbDddl8jGDv+spi5qPa8qEHiK7FwV2KpRE9
83wGPnYsAm9BxLFb4YrLYcDFOIGULuk2FtrPS512Qea1bXASuvYXEpQNpGbnTGVs
WXI9C+yjHztqyL2h8P6mlThPY9E9ue2fCqdgixfTFIF9Dm4SLHbphUS2iw7w1JgT
69s7of9+I9l5lsJ9cozf1rxrXX4V1u/SotUuNB3Fp8oB4C1fLBEhSlMcUJirz1E8
AziMCxS+VrRPDM+zfvpIJg3JljAh3PJHDiLu902v9w+Iplu1WyoB2aPfitxEhRN0
YwIDAQAB
-----END PUBLIC KEY-----
3 changes: 3 additions & 0 deletions src/jwt/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {verifyToken} from './validator';

export default verifyToken;
104 changes: 104 additions & 0 deletions src/jwt/signatureVerifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import AuthError from '../auth/authError';
import {KEYUTIL, KJUR} from 'jsrsasign';
const jwtDecoder = require('jwt-decode');

const ALLOWED_ALGORITHMS = ['RS256', 'HS256'];

/**
* Verifies that an ID token is signed with a supported algorithm (HS256 or RS256), and verifies the signature
* if signed with RS256. Note that this function is specific to the internals of this SDK, and not supported for general use.
* @param {String} idToken the ID token
* @param {Object} options required to verify an ID token's signature
* @param {String} [options.domain] the Auth0 domain of the token's issuer
* @returns {Promise} A promise that resolves to the decoded payload of the ID token, or rejects if the verification fails.
*/
export const verifySignature = (idToken, options) => {
let header, payload;

try {
header = jwtDecoder(idToken, {header: true});
payload = jwtDecoder(idToken);
} catch (err) {
return Promise.reject(
idTokenError({
error: 'token_decoding_error',
desc: 'ID token could not be decoded',
}),
);
}

const alg = header.alg;

if (!ALLOWED_ALGORITHMS.includes(alg)) {
return Promise.reject(
idTokenError({
error: 'invalid_algorithm',
desc: `Signature algorithm of "${alg}" is not supported. Expected "RS256" or "HS256".`,
}),
);
}

// HS256 tokens require private key, which cannot be stored securely in public clients.
// Since the ID token exchange is done via CODE with PKCE flow, we can skip signature verification in this case.
if (alg === 'HS256') {
return Promise.resolve(payload);
}

return getJwk(options.domain, header.kid).then(jwk => {
const pubKey = KEYUTIL.getKey(jwk);
const signatureValid = KJUR.jws.JWS.verify(idToken, pubKey, ['RS256']);

if (signatureValid) {
return Promise.resolve(payload);
}

return Promise.reject(
idTokenError({
error: 'invalid_signature',
desc: 'Invalid token signature',
}),
);
});
};

const getJwk = (domain, kid) => {
return getJwksUri(domain)
.then(uri => fetchJson(uri))
.then(jwk => {
const keys = jwk.keys;
const key = keys
.filter(
k => k.use === 'sig' && k.kty === 'RSA' && k.kid && (k.n && k.e),
)
.find(k => k.kid === kid);
return Promise.resolve(key);
})
.catch(err => {
return Promise.reject(
idTokenError({
error: 'key_retrieval_error',
desc: 'Unable to retrieve public keyset needed to verify token',
}),
);
});
};

const getJwksUri = domain => {
return fetch(`https://${domain}/.well-known/openid-configuration`)
.then(resp => resp.json())
.then(openIdConfig => openIdConfig.jwks_uri);
};

const fetchJson = uri => {
return fetch(uri).then(resp => resp.json());
};

const idTokenError = err => {
return new AuthError({
json: {
error: `a0.idtoken.${err.error}`,
error_description: err.desc,
},
status: 0,
});
};
216 changes: 216 additions & 0 deletions src/jwt/validator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import AuthError from '../auth/authError';
import {verifySignature} from './signatureVerifier';

// default clock skew, in seconds
const DEFAULT_LEEWAY = 60;

/**
* Verifies an ID token according to the OIDC specification. Note that this function is specific to the internals of this SDK,
* and is not supported for general use.
* @param {String} idToken the string token to verify
* @param {Object}options the options required to run this verification
* @returns {Promise} A promise that resolves if the verification is successful, or will reject the promise if validation fails
*/
export const verifyToken = (idToken, options) => {
if (typeof idToken !== 'string') {
return Promise.resolve();
}

return verifySignature(idToken, {domain: options.domain})
.then(payload => validateClaims(payload, options))
.then(() => Promise.resolve());
};

const validateClaims = (payload, opts) => {
// Issuer
if (typeof payload.iss !== 'string') {
return Promise.reject(
idTokenError({
error: 'missing_issuer_claim',
desc: 'Issuer (iss) claim must be a string present in the ID token',
}),
);
}

if (payload.iss !== 'https://' + opts.domain + '/') {
return Promise.reject(
idTokenError({
error: 'invalid_issuer_claim',
desc: `Issuer (iss) claim mismatch in the ID token; expected "https://${opts.domain}/", found "${payload.iss}"`,
}),
);
}

// Subject
if (typeof payload.sub !== 'string') {
return Promise.reject(
idTokenError({
error: 'missing_subject_claim',
desc: 'Subject (sub) claim must be a string present in the ID token',
}),
);
}

// Audience
if (!(typeof payload.aud === 'string' || Array.isArray(payload.aud))) {
return Promise.reject(
idTokenError({
error: 'missing_audience_claim',
desc:
'Audience (aud) claim must be a string or array of strings present in the ID token',
}),
);
}

if (Array.isArray(payload.aud) && !payload.aud.includes(opts.clientId)) {
return Promise.reject(
idTokenError({
error: 'invalid_audience_claim',
desc: `Audience (aud) claim mismatch in the ID token; expected "${
opts.clientId
}" but was not one of "${payload.aud.join(', ')}"`,
}),
);
} else if (typeof payload.aud === 'string' && payload.aud !== opts.clientId) {
return Promise.reject(
idTokenError({
error: 'invalid_audience_claim',
desc: `Audience (aud) claim mismatch in the ID token; expected "${opts.clientId}" but found "${payload.aud}"`,
}),
);
}

//--Time validation (epoch)--
const now = opts._clock
? getEpochTimeInSeconds(opts._clock)
: getEpochTimeInSeconds(new Date());
const leeway = typeof opts.leeway === 'number' ? opts.leeway : DEFAULT_LEEWAY;

//Expires at
if (typeof payload.exp !== 'number') {
return Promise.reject(
idTokenError({
error: 'missing_expires_at_claim',
desc:
'Expiration Time (exp) claim must be a number present in the ID token',
}),
);
}

const expTime = payload.exp + leeway;

if (now > expTime) {
return Promise.reject(
idTokenError({
error: 'invalid_expires_at_claim',
desc: `Expiration Time (exp) claim error in the ID token; current time "${now}" is after expiration time "${expTime}"`,
}),
);
}

//Issued at
if (typeof payload.iat !== 'number') {
return Promise.reject(
idTokenError({
error: 'missing_issued_at_claim',
desc: 'Issued At (iat) claim must be a number present in the ID token',
}),
);
}

const iatTime = payload.iat - leeway;

if (now < iatTime) {
return Promise.reject(
idTokenError({
error: 'invalid_issued_at_claim',
desc: `Issued At (iat) claim error in the ID token; current time "${now}" is before issued at time "${iatTime}"`,
}),
);
}

//Nonce
if (opts.nonce) {
if (typeof payload.nonce !== 'string') {
return Promise.reject(
idTokenError({
error: 'missing_nonce_claim',
desc: 'Nonce (nonce) claim must be a string present in the ID token',
}),
);
}
if (payload.nonce !== opts.nonce) {
return Promise.reject(
idTokenError({
error: 'invalid_nonce_claim',
desc: `Nonce (nonce) claim mismatch in the ID token; expected "${opts.nonce}", found "${payload.nonce}"`,
}),
);
}
}

//Authorized party
if (Array.isArray(payload.aud) && payload.aud.length > 1) {
if (typeof payload.azp !== 'string') {
return Promise.reject(
idTokenError({
error: 'missing_authorized_party_claim',
desc:
'Authorized Party (azp) claim must be a string present in the ID token when Audience (aud) claim has multiple values',
}),
);
}

if (payload.azp !== opts.clientId) {
return Promise.reject(
idTokenError({
error: 'invalid_authorized_party_claim',
desc: `Authorized Party (azp) claim mismatch in the ID token; expected "${opts.clientId}", found "${payload.azp}"`,
}),
);
}
}

//Authentication time
if (typeof opts.maxAge === 'number') {
if (typeof payload.auth_time !== 'number') {
return Promise.reject(
idTokenError({
error: 'missing_authorization_time_claim',
desc:
'Authentication Time (auth_time) claim must be a number present in the ID token when Max Age (max_age) is specified',
}),
);
}

const authValidUntil = payload.auth_time + opts.maxAge + leeway;

if (now > authValidUntil) {
return Promise.reject(
idTokenError({
error: 'invalid_authorization_time_claim',
desc: `Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time "${now}" is after last auth time "${authValidUntil}"`,
}),
);
}
}

return Promise.resolve();
};

const getEpochTimeInSeconds = date => {
return Math.round(date.getTime() / 1000);
};

const idTokenError = ({
error = 'verification_error',
desc = 'Error verifying ID token',
} = {}) => {
return new AuthError({
json: {
error: `a0.idtoken.${error}`,
error_description: desc,
},
status: 0,
});
};
Loading