@mvx/identity
Version:
identity is oidc for mvc, type-mvc is base on koa. Decorator, Ioc, AOP mvc framework on server.
478 lines (476 loc) • 20.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Resolver = exports.OIDCStrategy = void 0;
const tslib_1 = require("tslib");
const ioc_1 = require("@tsdi/ioc");
const components_1 = require("@tsdi/components");
const Strategy_1 = require("./Strategy");
const querystring_1 = require("querystring");
const errors_1 = require("../errors");
const stores_1 = require("../stores");
const url_1 = require("url");
const oauth2_1 = require("./oauth2");
const results_1 = require("./results");
const webfinger = require('webfinger').webfinger;
const request = require('request');
/**
* OIDC authenticate strategy
*
* @export
* @class OIDCStrategy
* @extends {Strategy}
* @implements {AfterInit}
*/
let OIDCStrategy = class OIDCStrategy extends Strategy_1.Strategy {
async onAfterInit() {
if (!this.name) {
this.name = 'openidconnect';
}
if (!this.identifierField) {
this.identifierField = 'openid_identifier';
}
if (!this.sessionKey) {
this.sessionKey = ('oauth2:' + url_1.parse(this.authorizationURL).hostname);
}
if (this.stateStore) {
this.stateStore = new stores_1.SessionStore(this.sessionKey);
}
if (!(this.authorizationURL && this.tokenURL) && this.options.getClient) {
throw new Error('OpenID Connect authentication requires getClientCallback option');
}
}
async authenticate(ctx, options) {
options = options || {};
if (ctx.query && ctx.query.error) {
if (ctx.query.error === 'access_denied') {
return new results_1.FailResult(ctx.query.error_description, 403);
}
else {
throw new errors_1.OIDCError(ctx.query.error_description, ctx.query.error, ctx.query.error_uri);
}
}
if (ctx.query && ctx.query.code) {
let state = ctx.query.state;
const { result: verifiedResult, state: meta, message: verifiedMsg } = await this.stateStore.verify(ctx, state);
if (!verifiedResult) {
return new results_1.FailResult(verifiedMsg, 403);
}
const code = ctx.query.code;
let callbackURL = meta.callbackURL;
let oauth2 = new oauth2_1.OAuth2(meta.clientID, meta.clientSecret, '', meta.authorizationURL, meta.tokenURL, meta.customHeaders);
let accessToken;
let refreshToken;
let accessTokenResult;
try {
({
accessToken,
refreshToken,
result: accessTokenResult,
} = await oauth2.getOAuthAccessToken(code, { grant_type: 'authorization_code', redirect_uri: callbackURL }));
}
catch (err) {
throw this.parseOAuthError(err);
}
var idToken = accessTokenResult['id_token'];
if (!idToken) {
throw new Error('ID Token not present in token response');
}
let idTokenSegments = idToken.split('.'), jwtClaimsStr, jwtClaims;
try {
jwtClaimsStr = new Buffer(idTokenSegments[1], 'base64').toString();
jwtClaims = JSON.parse(jwtClaimsStr);
}
catch (ex) {
throw ex;
}
var missing = ['iss', 'sub', 'aud', 'exp', 'iat'].filter(param => !jwtClaims[param]);
if (missing.length) {
throw new Error('id token is missing required parameter(s) - ' + missing.join(', '));
}
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 1.
if (jwtClaims.iss !== meta.issuer) {
throw new Error('id token not issued by correct OpenID provider - ' +
'expected: ' + meta.issuer + ' | from: ' + jwtClaims.iss);
}
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - checks 2 and 3.
if (typeof jwtClaims.aud === 'string') {
if (jwtClaims.aud !== meta.clientID) {
throw new Error('aud parameter does not include this client - is: '
+ jwtClaims.aud + '| expected: ' + meta.clientID);
}
}
else if (ioc_1.isArray(jwtClaims.aud)) {
if (jwtClaims.aud.indexOf(meta.clientID) === -1) {
throw new Error('aud parameter does not include this client - is: ' +
jwtClaims.aud + ' | expected to include: ' + meta.clientID);
}
if (jwtClaims.length > 1 && !jwtClaims.azp) {
throw new Error('azp parameter required with multiple audiences');
}
}
else {
throw new Error('Invalid aud parameter type');
}
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 4.
if (jwtClaims.azp && jwtClaims.azp !== meta.clientID) {
throw new Error('this client is not the authorized party - ' +
'expected: ' + meta.clientID + ' | is: ' + jwtClaims.azp);
}
// Possible TODO: Add accounting for some clock skew.
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 5.
if (jwtClaims.exp < (Date.now() / 1000)) {
throw new Error('id token has expired');
}
// Note: https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - checks 6 and 7 are out of scope of this library.
// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 8.
if (meta.params.max_age && (!jwtClaims.auth_time || ((meta.timestamp - meta.params.max_age) > jwtClaims.auth_time))) {
throw new Error('auth_time in id_token not included or too old');
}
if (meta.params.nonce && (!jwtClaims.nonce || jwtClaims.nonce !== meta.params.nonce)) {
throw new Error('Invalid nonce in id_token');
}
var iss = jwtClaims.iss;
var sub = jwtClaims.sub;
// Prior to OpenID Connect Basic Client Profile 1.0 - draft 22, the
// "sub" claim was named "user_id". Many providers still issue the
// claim under the old field, so fallback to that.
if (!sub) {
sub = jwtClaims.user_id;
}
let load = await this.shouldLoadUserProfile(iss, sub);
let profile = undefined;
if (load) {
var parsed = url_1.parse(meta.userInfoURL, true);
parsed.query['schema'] = 'openid';
delete parsed.search;
var userInfoURL = url_1.format(parsed);
// NOTE: We are calling node-oauth's internal `_request` function (as
// opposed to `get`) in order to send the access token in the
// `Authorization` header rather than as a query parameter.
//
// Additionally, the master branch of node-oauth (as of
// 2013-02-16) will include the access token in *both* headers
// and query parameters, which is a violation of the spec.
// Setting the fifth argument of `_request` to `null` works
// around this issue. I've noted this in comments here:
// https://github.com/ciaranj/node-oauth/issues/117
const { result: body, response: res } = await oauth2.request('GET', userInfoURL, { 'Authorization': 'Bearer ' + accessToken, 'Accept': 'application/json' }, null, null);
profile = {};
try {
var json = JSON.parse(body);
profile.id = json.sub;
// Prior to OpenID Connect Basic Client Profile 1.0 - draft 22, the
// "sub" key was named "user_id". Many providers still use the old
// key, so fallback to that.
if (!profile.id) {
profile.id = json.user_id;
}
profile.displayName = json.name;
profile.name = {
familyName: json.family_name,
givenName: json.given_name,
middleName: json.middle_name
};
profile._raw = body;
profile._json = json;
}
catch (ex) {
throw ex;
}
}
const { user, info } = await this.verify(ctx, this.passReqToCallback ? iss : undefined, sub, profile, jwtClaims, accessToken, refreshToken, accessTokenResult);
if (!user) {
// TODO, not sure 401 is the correct meaning
return new results_1.FailResult(info, 401);
}
return new results_1.SuccessResult(options, user, info);
}
else {
// The request being authenticated is initiating OpenID Connect
// authentication. Prior to redirecting to the provider, configuration will
// be loaded. The configuration is typically either pre-configured or
// discovered dynamically. When using dynamic discovery, a user supplies
// their identifer as input.
let identifier;
let idfield = this.identifierField;
if (ctx.request.body && ctx.request.body[idfield]) {
identifier = ctx.request.body[idfield];
}
else if (ctx.query && ctx.query[idfield]) {
identifier = ctx.query[idfield];
}
let meta = await this.getConfigure(identifier);
let callbackURL = options.callbackURL || this.callbackURL;
if (callbackURL) {
const parsed = url_1.parse(callbackURL);
if (!parsed.protocol) {
// The callback URL is relative, resolve a fully qualified URL from the
// URL of the originating request.
callbackURL = url_1.resolve(ctx.request.origin, callbackURL);
}
}
meta.callbackURL = callbackURL;
let params = await this.authorizationParams(options);
params['response_type'] = 'code';
params['client_id'] = meta.clientID;
if (callbackURL) {
params.redirect_uri = callbackURL;
}
let scope = options.scope || this.scope;
if (ioc_1.isArray(scope)) {
scope = scope.join(' ');
}
if (scope) {
params.scope = 'openid ' + scope;
}
else {
params.scope = 'openid';
}
// Optional Parameters
['max_age', 'ui_locals', 'id_token_hint', 'login_hint', 'acr_values']
.filter(x => { return x in meta; })
.forEach(y => { params[y] = meta[y]; });
if (meta.display && ['page', 'popup', 'touch', 'wap'].indexOf(meta.display) !== -1) {
params.display = meta.display;
}
if (meta.prompt && ['none', 'login', 'consent', 'select_account'].indexOf(meta.prompt) !== -1) {
params.prompt = meta.prompt;
}
if (meta.nonce && typeof meta.nonce === 'boolean') {
params.nonce = stores_1.OIDCUtils.uid(20);
}
if (meta.nonce && typeof meta.nonce === 'number') {
params.nonce = stores_1.OIDCUtils.uid(meta.nonce);
}
if (meta.nonce && typeof meta.nonce === 'string') {
params.nonce = meta.nonce;
}
if (params.max_age) {
meta.timestamp = Math.floor(Date.now() / 1000);
}
meta.params = params;
for (let param in params) {
if (meta[param]) {
delete meta[param]; // Remove redundant information.
}
}
// State Storage/Management
try {
let state = await this.stateStore.store(ctx, meta);
params.state = state;
var location = meta.authorizationURL + '?' + querystring_1.stringify(params);
return new results_1.RedirectResult(location);
}
catch (ex) {
throw ex;
}
}
}
async shouldLoadUserProfile(issuer, subject) {
if (this.skipUserProfile) {
return ioc_1.isFunction(this.skipUserProfile) ? await this.skipUserProfile(issuer, subject) : false;
}
return true;
}
parseOAuthError(err) {
let e;
if (err instanceof oauth2_1.OAuth2Error) {
try {
const json = JSON.parse(err.message);
if (json.error) {
e = new errors_1.InternalOAuthError(`Failed to obtain access token:${json.error_description}`, json.error);
}
}
catch (_) {
// console.warn('============This error can be ignored==============');
// console.warn(_);
// console.warn('===================================================');
}
}
if (!e) {
err.message = `Failed to obtain access token:${err.message}`;
e = err;
}
return e;
}
async getConfigure(identifier) {
if (this.authorizationURL && this.tokenURL) {
return await this.manualConfigure(identifier);
}
else {
return await this.dynamicConfigure(identifier);
}
}
async dynamicConfigure(identifier) {
let issuer = await this.injector.getInstance(Resolver).resolve(identifier);
let url = issuer + '/.well-known/openid-configuration';
let defer = ioc_1.PromiseUtil.defer();
request.get(url, async (err, res, body) => {
if (err) {
return defer.reject(err);
}
if (res.statusCode !== 200) {
return defer.reject(new Error('Unexpected status code from OpenID provider configuration: ' + res.statusCode));
}
var config = {};
try {
var json = JSON.parse(body);
config.issuer = json.issuer;
config.authorizationURL = json.authorization_endpoint;
config.tokenURL = json.token_endpoint;
config.userInfoURL = json.userinfo_endpoint;
config.registrationURL = json.registration_endpoint;
config._raw = json;
}
catch (ex) {
return defer.reject(new Error('Failed to parse OpenID provider configuration'));
}
// TODO: Pass registrationURL here.
let client = await this.options.getClient(config.issuer);
config.clientID = client.id;
config.clientSecret = client.secret;
if (client.redirectURIs) {
config.callbackURL = client.redirectURIs[0];
}
return defer.resolve(config);
});
return defer.promise;
}
async manualConfigure(identifier) {
let missing = ['issuer', 'authorizationURL', 'tokenURL', 'clientID', 'clientSecret'].filter(opt => !this.options[opt]);
if (missing.length) {
throw new Error('Manual OpenID configuration is missing required parameter(s) - ' + missing.join(', '));
}
let params = {
issuer: this.issuer,
authorizationURL: this.authorizationURL,
tokenURL: this.tokenURL,
userInfoURL: this.userInfoURL,
clientID: this.clientID,
clientSecret: this.clientSecret,
callbackURL: this.callbackURL
};
Object.keys(this.options).map(opt => {
if (['nonce', 'display', 'prompt', 'max_age', 'ui_locals', 'id_token_hint', 'login_hint', 'acr_values'].indexOf(opt) !== -1) {
params[opt] = this.options[opt];
}
});
return params;
}
static ρAnn() {
return { "name": "OIDCStrategy", "params": { "authenticate": ["ctx", "options"], "shouldLoadUserProfile": ["issuer", "subject"], "parseOAuthError": ["err"], "getConfigure": ["identifier"], "dynamicConfigure": ["identifier"], "manualConfigure": ["identifier"] } };
}
};
tslib_1.__decorate([
components_1.Input('store'),
tslib_1.__metadata("design:type", stores_1.StateStore)
], OIDCStrategy.prototype, "stateStore", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", Object)
], OIDCStrategy.prototype, "scope", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "identifierField", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "issuer", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "sessionKey", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "tokenURL", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "authorizationURL", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "clientID", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "clientSecret", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "callbackURL", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "userInfoURL", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", Object)
], OIDCStrategy.prototype, "customHeaders", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", Function)
], OIDCStrategy.prototype, "verify", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", String)
], OIDCStrategy.prototype, "passReqToCallback", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", Object)
], OIDCStrategy.prototype, "skipUserProfile", void 0);
tslib_1.__decorate([
components_1.Input(),
tslib_1.__metadata("design:type", Function)
], OIDCStrategy.prototype, "authorizationParams", void 0);
tslib_1.__decorate([
ioc_1.Inject(components_1.TemplateOptionToken),
tslib_1.__metadata("design:type", Object)
], OIDCStrategy.prototype, "options", void 0);
tslib_1.__decorate([
ioc_1.Inject(ioc_1.INJECTOR),
tslib_1.__metadata("design:type", Object)
], OIDCStrategy.prototype, "injector", void 0);
OIDCStrategy = tslib_1.__decorate([
components_1.Component({
selector: 'oidc'
})
], OIDCStrategy);
exports.OIDCStrategy = OIDCStrategy;
const REL = 'http://openid.net/specs/connect/1.0/issuer';
let Resolver = class Resolver {
resolve(identifier) {
let defer = ioc_1.PromiseUtil.defer();
webfinger(identifier, REL, { webfingerOnly: true }, (err, jrd) => {
if (err) {
return defer.reject(err);
}
if (!jrd.links) {
return defer.reject(new errors_1.NoOpenIDError('No links in resource descriptor', jrd));
}
let issuer;
for (let i = 0; i < jrd.links.length; i++) {
let link = jrd.links[i];
if (link.rel === REL) {
issuer = link.href;
break;
}
}
if (!issuer) {
return defer.reject(new errors_1.NoOpenIDError('No OpenID Connect issuer in resource descriptor', jrd));
}
return defer.resolve(issuer);
});
return defer.promise;
}
static ρAnn() {
return { "name": "Resolver", "params": { "resolve": ["identifier"] } };
}
};
Resolver = tslib_1.__decorate([
ioc_1.Singleton()
], Resolver);
exports.Resolver = Resolver;
//# sourceMappingURL=../sourcemaps/passports/OIDCStrategy.js.map