oidc-client
Version:
OpenID Connect (OIDC) & OAuth2 client library
486 lines (398 loc) • 20.7 kB
JavaScript
// Copyright (c) Brock Allen & Dominick Baier. All rights reserved.
// Licensed under the Apache License, Version 2.0. See LICENSE in the project root for license information.
import { Log } from './Log.js';
import { MetadataService } from './MetadataService.js';
import { UserInfoService } from './UserInfoService.js';
import { TokenClient } from './TokenClient.js';
import { ErrorResponse } from './ErrorResponse.js';
import { JoseUtil } from './JoseUtil.js';
const ProtocolClaims = ["nonce", "at_hash", "iat", "nbf", "exp", "aud", "iss", "c_hash"];
export class ResponseValidator {
constructor(settings,
MetadataServiceCtor = MetadataService,
UserInfoServiceCtor = UserInfoService,
joseUtil = JoseUtil,
TokenClientCtor = TokenClient) {
if (!settings) {
Log.error("ResponseValidator.ctor: No settings passed to ResponseValidator");
throw new Error("settings");
}
this._settings = settings;
this._metadataService = new MetadataServiceCtor(this._settings);
this._userInfoService = new UserInfoServiceCtor(this._settings);
this._joseUtil = joseUtil;
this._tokenClient = new TokenClientCtor(this._settings);
}
validateSigninResponse(state, response) {
Log.debug("ResponseValidator.validateSigninResponse");
return this._processSigninParams(state, response).then(response => {
Log.debug("ResponseValidator.validateSigninResponse: state processed");
return this._validateTokens(state, response).then(response => {
Log.debug("ResponseValidator.validateSigninResponse: tokens validated");
return this._processClaims(state, response).then(response => {
Log.debug("ResponseValidator.validateSigninResponse: claims processed");
return response;
});
});
});
}
validateSignoutResponse(state, response) {
if (state.id !== response.state) {
Log.error("ResponseValidator.validateSignoutResponse: State does not match");
return Promise.reject(new Error("State does not match"));
}
// now that we know the state matches, take the stored data
// and set it into the response so callers can get their state
// this is important for both success & error outcomes
Log.debug("ResponseValidator.validateSignoutResponse: state validated");
response.state = state.data;
if (response.error) {
Log.warn("ResponseValidator.validateSignoutResponse: Response was error", response.error);
return Promise.reject(new ErrorResponse(response));
}
return Promise.resolve(response);
}
_processSigninParams(state, response) {
if (state.id !== response.state) {
Log.error("ResponseValidator._processSigninParams: State does not match");
return Promise.reject(new Error("State does not match"));
}
if (!state.client_id) {
Log.error("ResponseValidator._processSigninParams: No client_id on state");
return Promise.reject(new Error("No client_id on state"));
}
if (!state.authority) {
Log.error("ResponseValidator._processSigninParams: No authority on state");
return Promise.reject(new Error("No authority on state"));
}
// this allows the authority to be loaded from the signin state
if (!this._settings.authority) {
this._settings.authority = state.authority;
}
// ensure we're using the correct authority if the authority is not loaded from signin state
else if (this._settings.authority && this._settings.authority !== state.authority) {
Log.error("ResponseValidator._processSigninParams: authority mismatch on settings vs. signin state");
return Promise.reject(new Error("authority mismatch on settings vs. signin state"));
}
// this allows the client_id to be loaded from the signin state
if (!this._settings.client_id) {
this._settings.client_id = state.client_id;
}
// ensure we're using the correct client_id if the client_id is not loaded from signin state
else if (this._settings.client_id && this._settings.client_id !== state.client_id) {
Log.error("ResponseValidator._processSigninParams: client_id mismatch on settings vs. signin state");
return Promise.reject(new Error("client_id mismatch on settings vs. signin state"));
}
// now that we know the state matches, take the stored data
// and set it into the response so callers can get their state
// this is important for both success & error outcomes
Log.debug("ResponseValidator._processSigninParams: state validated");
response.state = state.data;
if (response.error) {
Log.warn("ResponseValidator._processSigninParams: Response was error", response.error);
return Promise.reject(new ErrorResponse(response));
}
if (state.nonce && !response.id_token) {
Log.error("ResponseValidator._processSigninParams: Expecting id_token in response");
return Promise.reject(new Error("No id_token in response"));
}
if (!state.nonce && response.id_token) {
Log.error("ResponseValidator._processSigninParams: Not expecting id_token in response");
return Promise.reject(new Error("Unexpected id_token in response"));
}
if (state.code_verifier && !response.code) {
Log.error("ResponseValidator._processSigninParams: Expecting code in response");
return Promise.reject(new Error("No code in response"));
}
if (!state.code_verifier && response.code) {
Log.error("ResponseValidator._processSigninParams: Not expecting code in response");
return Promise.reject(new Error("Unexpected code in response"));
}
if (!response.scope) {
// if there's no scope on the response, then assume all scopes granted (per-spec) and copy over scopes from original request
response.scope = state.scope;
}
return Promise.resolve(response);
}
_processClaims(state, response) {
if (response.isOpenIdConnect) {
Log.debug("ResponseValidator._processClaims: response is OIDC, processing claims");
response.profile = this._filterProtocolClaims(response.profile);
if (state.skipUserInfo !== true && this._settings.loadUserInfo && response.access_token) {
Log.debug("ResponseValidator._processClaims: loading user info");
return this._userInfoService.getClaims(response.access_token).then(claims => {
Log.debug("ResponseValidator._processClaims: user info claims received from user info endpoint");
if (claims.sub !== response.profile.sub) {
Log.error("ResponseValidator._processClaims: sub from user info endpoint does not match sub in id_token");
return Promise.reject(new Error("sub from user info endpoint does not match sub in id_token"));
}
response.profile = this._mergeClaims(response.profile, claims);
Log.debug("ResponseValidator._processClaims: user info claims received, updated profile:", response.profile);
return response;
});
}
else {
Log.debug("ResponseValidator._processClaims: not loading user info");
}
}
else {
Log.debug("ResponseValidator._processClaims: response is not OIDC, not processing claims");
}
return Promise.resolve(response);
}
_mergeClaims(claims1, claims2) {
var result = Object.assign({}, claims1);
for (let name in claims2) {
var values = claims2[name];
if (!Array.isArray(values)) {
values = [values];
}
for (let i = 0; i < values.length; i++) {
let value = values[i];
if (!result[name]) {
result[name] = value;
}
else if (Array.isArray(result[name])) {
if (result[name].indexOf(value) < 0) {
result[name].push(value);
}
}
else if (result[name] !== value) {
if (typeof value === 'object' && this._settings.mergeClaims) {
result[name] = this._mergeClaims(result[name], value);
}
else {
result[name] = [result[name], value];
}
}
}
}
return result;
}
_filterProtocolClaims(claims) {
Log.debug("ResponseValidator._filterProtocolClaims, incoming claims:", claims);
var result = Object.assign({}, claims);
if (this._settings._filterProtocolClaims) {
ProtocolClaims.forEach(type => {
delete result[type];
});
Log.debug("ResponseValidator._filterProtocolClaims: protocol claims filtered", result);
}
else {
Log.debug("ResponseValidator._filterProtocolClaims: protocol claims not filtered")
}
return result;
}
_validateTokens(state, response) {
if (response.code) {
Log.debug("ResponseValidator._validateTokens: Validating code");
return this._processCode(state, response);
}
if (response.id_token) {
if (response.access_token) {
Log.debug("ResponseValidator._validateTokens: Validating id_token and access_token");
return this._validateIdTokenAndAccessToken(state, response);
}
Log.debug("ResponseValidator._validateTokens: Validating id_token");
return this._validateIdToken(state, response);
}
Log.debug("ResponseValidator._validateTokens: No code to process or id_token to validate");
return Promise.resolve(response);
}
_processCode(state, response) {
var request = {
client_id: state.client_id,
client_secret: state.client_secret,
code : response.code,
redirect_uri: state.redirect_uri,
code_verifier: state.code_verifier
};
if (state.extraTokenParams && typeof(state.extraTokenParams) === 'object') {
Object.assign(request, state.extraTokenParams);
}
return this._tokenClient.exchangeCode(request).then(tokenResponse => {
for(var key in tokenResponse) {
response[key] = tokenResponse[key];
}
if (response.id_token) {
Log.debug("ResponseValidator._processCode: token response successful, processing id_token");
return this._validateIdTokenAttributes(state, response);
}
else {
Log.debug("ResponseValidator._processCode: token response successful, returning response");
}
return response;
});
}
_validateIdTokenAttributes(state, response) {
return this._metadataService.getIssuer().then(issuer => {
let audience = state.client_id;
let clockSkewInSeconds = this._settings.clockSkew;
Log.debug("ResponseValidator._validateIdTokenAttributes: Validaing JWT attributes; using clock skew (in seconds) of: ", clockSkewInSeconds);
return this._settings.getEpochTime().then(now => {
return this._joseUtil.validateJwtAttributes(response.id_token, issuer, audience, clockSkewInSeconds, now).then(payload => {
if (state.nonce && state.nonce !== payload.nonce) {
Log.error("ResponseValidator._validateIdTokenAttributes: Invalid nonce in id_token");
return Promise.reject(new Error("Invalid nonce in id_token"));
}
if (!payload.sub) {
Log.error("ResponseValidator._validateIdTokenAttributes: No sub present in id_token");
return Promise.reject(new Error("No sub present in id_token"));
}
response.profile = payload;
return response;
});
});
});
}
_validateIdTokenAndAccessToken(state, response) {
return this._validateIdToken(state, response).then(response => {
return this._validateAccessToken(response);
});
}
_getSigningKeyForJwt(jwt) {
return this._metadataService.getSigningKeys().then(keys => {
const kid = jwt.header.kid;
if (!keys) {
Log.error("ResponseValidator._validateIdToken: No signing keys from metadata");
return Promise.reject(new Error("No signing keys from metadata"));
}
Log.debug("ResponseValidator._validateIdToken: Received signing keys");
let key;
if (!kid) {
keys = this._filterByAlg(keys, jwt.header.alg);
if (keys.length > 1) {
Log.error("ResponseValidator._validateIdToken: No kid found in id_token and more than one key found in metadata");
return Promise.reject(new Error("No kid found in id_token and more than one key found in metadata"));
} else {
// kid is mandatory only when there are multiple keys in the referenced JWK Set document
// see http://openid.net/specs/openid-connect-core-1_0.html#Signing
key = keys[0];
}
} else {
key = keys.filter(key => {
return key.kid === kid;
})[0];
}
return Promise.resolve(key);
});
}
_getSigningKeyForJwtWithSingleRetry(jwt) {
return this._getSigningKeyForJwt(jwt).then(key => {
// Refreshing signingKeys if no suitable verification key is present for given jwt header.
if (!key) {
// set to undefined, to trigger network call to jwks_uri.
this._metadataService.resetSigningKeys();
return this._getSigningKeyForJwt(jwt);
} else {
return Promise.resolve(key);
}
});
}
_validateIdToken(state, response) {
if (!state.nonce) {
Log.error("ResponseValidator._validateIdToken: No nonce on state");
return Promise.reject(new Error("No nonce on state"));
}
let jwt = this._joseUtil.parseJwt(response.id_token);
if (!jwt || !jwt.header || !jwt.payload) {
Log.error("ResponseValidator._validateIdToken: Failed to parse id_token", jwt);
return Promise.reject(new Error("Failed to parse id_token"));
}
if (state.nonce !== jwt.payload.nonce) {
Log.error("ResponseValidator._validateIdToken: Invalid nonce in id_token");
return Promise.reject(new Error("Invalid nonce in id_token"));
}
return this._metadataService.getIssuer().then(issuer => {
Log.debug("ResponseValidator._validateIdToken: Received issuer");
return this._getSigningKeyForJwtWithSingleRetry(jwt).then(key => {
if (!key) {
Log.error("ResponseValidator._validateIdToken: No key matching kid or alg found in signing keys");
return Promise.reject(new Error("No key matching kid or alg found in signing keys"));
}
let audience = state.client_id;
let clockSkewInSeconds = this._settings.clockSkew;
Log.debug("ResponseValidator._validateIdToken: Validaing JWT; using clock skew (in seconds) of: ", clockSkewInSeconds);
return this._joseUtil.validateJwt(response.id_token, key, issuer, audience, clockSkewInSeconds).then(()=>{
Log.debug("ResponseValidator._validateIdToken: JWT validation successful");
if (!jwt.payload.sub) {
Log.error("ResponseValidator._validateIdToken: No sub present in id_token");
return Promise.reject(new Error("No sub present in id_token"));
}
response.profile = jwt.payload;
return response;
});
});
});
}
_filterByAlg(keys, alg){
var kty = null;
if (alg.startsWith("RS")) {
kty = "RSA";
}
else if (alg.startsWith("PS")) {
kty = "PS";
}
else if (alg.startsWith("ES")) {
kty = "EC";
}
else {
Log.debug("ResponseValidator._filterByAlg: alg not supported: ", alg);
return [];
}
Log.debug("ResponseValidator._filterByAlg: Looking for keys that match kty: ", kty);
keys = keys.filter(key => {
return key.kty === kty;
});
Log.debug("ResponseValidator._filterByAlg: Number of keys that match kty: ", kty, keys.length);
return keys;
}
_validateAccessToken(response) {
if (!response.profile) {
Log.error("ResponseValidator._validateAccessToken: No profile loaded from id_token");
return Promise.reject(new Error("No profile loaded from id_token"));
}
if (!response.profile.at_hash) {
Log.error("ResponseValidator._validateAccessToken: No at_hash in id_token");
return Promise.reject(new Error("No at_hash in id_token"));
}
if (!response.id_token) {
Log.error("ResponseValidator._validateAccessToken: No id_token");
return Promise.reject(new Error("No id_token"));
}
let jwt = this._joseUtil.parseJwt(response.id_token);
if (!jwt || !jwt.header) {
Log.error("ResponseValidator._validateAccessToken: Failed to parse id_token", jwt);
return Promise.reject(new Error("Failed to parse id_token"));
}
var hashAlg = jwt.header.alg;
if (!hashAlg || hashAlg.length !== 5) {
Log.error("ResponseValidator._validateAccessToken: Unsupported alg:", hashAlg);
return Promise.reject(new Error("Unsupported alg: " + hashAlg));
}
var hashBits = hashAlg.substr(2, 3);
if (!hashBits) {
Log.error("ResponseValidator._validateAccessToken: Unsupported alg:", hashAlg, hashBits);
return Promise.reject(new Error("Unsupported alg: " + hashAlg));
}
hashBits = parseInt(hashBits);
if (hashBits !== 256 && hashBits !== 384 && hashBits !== 512) {
Log.error("ResponseValidator._validateAccessToken: Unsupported alg:", hashAlg, hashBits);
return Promise.reject(new Error("Unsupported alg: " + hashAlg));
}
let sha = "sha" + hashBits;
var hash = this._joseUtil.hashString(response.access_token, sha);
if (!hash) {
Log.error("ResponseValidator._validateAccessToken: access_token hash failed:", sha);
return Promise.reject(new Error("Failed to validate at_hash"));
}
var left = hash.substr(0, hash.length / 2);
var left_b64u = this._joseUtil.hexToBase64Url(left);
if (left_b64u !== response.profile.at_hash) {
Log.error("ResponseValidator._validateAccessToken: Failed to validate at_hash", left_b64u, response.profile.at_hash);
return Promise.reject(new Error("Failed to validate at_hash"));
}
Log.debug("ResponseValidator._validateAccessToken: success");
return Promise.resolve(response);
}
}