UNPKG

@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
"use strict"; 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