From cec9f1580c02395ce630ae510710664a87f59301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20Carlos=20Branda=CC=83o?= Date: Tue, 19 Feb 2019 10:44:25 +0000 Subject: [PATCH 1/2] Fixed missing headers required by ory hydra authentication server during authorization code grant flow and parsing received tokens. --- .gitignore | 118 ++++++++++++++ projects/lib/src/auth.config.ts | 5 + projects/lib/src/oauth-service.ts | 247 +++++++++++++++++++++++++++--- 3 files changed, 352 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index ee5c9d83..db29b503 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,121 @@ +# Created by https://www.gitignore.io/api/node,angular +# Edit at https://www.gitignore.io/?templates=node,angular + +### Angular ### +## Angular ## +# compiled output +/dist +/tmp +/app/**/*.js +/app/**/*.js.map + +# dependencies +/node_modules +/bower_components + +# IDEs and editors +/.idea + +# misc +/.sass-cache +/connect.lock +/coverage/* +/libpeerconnection.log +npm-debug.log +testem.log +/typings + +# e2e +/e2e/*.js +/e2e/*.map + +#System Files +.DS_Store + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# End of https://www.gitignore.io/api/node,angular + # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output diff --git a/projects/lib/src/auth.config.ts b/projects/lib/src/auth.config.ts index 0273a667..c7b5c1dd 100644 --- a/projects/lib/src/auth.config.ts +++ b/projects/lib/src/auth.config.ts @@ -215,6 +215,11 @@ export class AuthConfig { */ public useHttpBasicAuthForPasswordFlow? = false; + /* + * set this to true to use HTTP BASIC auth for authorization code flow + */ + public useHttpBasicAuthForAuthorizationCodeFlow? = false; + public disableNonceCheck? = false; constructor(json?: Partial) { diff --git a/projects/lib/src/oauth-service.ts b/projects/lib/src/oauth-service.ts index 64a765c4..061c2f86 100644 --- a/projects/lib/src/oauth-service.ts +++ b/projects/lib/src/oauth-service.ts @@ -699,18 +699,30 @@ export class OAuthService extends AuthConfig { return this.fetchToken(params); } + public refreshTokenUsingState(state: string): Promise { + + let params = new HttpParams() + .set('grant_type', 'refresh_token') + .set('refresh_token', this._storage.getItem('refresh_token')) + .set('scope', this.scope); + if (this.dummyClientSecret) { + params = params.set('client_secret', this.dummyClientSecret); + } + return this.fetchTokenUsingState(params,state); + } + /** * Get token using an intermediate code. Works for the Authorization Code flow. */ - private getTokenFromCode(code: string): Promise { + private getTokenFromCode(code: string,state: string): Promise { let params = new HttpParams() .set('grant_type', 'authorization_code') .set('code', code) .set('redirect_uri', this.redirectUri); - return this.fetchToken(params); + return this.fetchTokenUsingState(params,state); } - private fetchToken(params: HttpParams): Promise { + private fetchTokenUsingState(params: HttpParams,state: string): Promise { if (!this.validateUrlForHttps(this.tokenEndpoint)) { throw new Error( @@ -719,18 +731,27 @@ export class OAuthService extends AuthConfig { } return new Promise((resolve, reject) => { - params = params.set('client_id', this.clientId); - - if (this.customQueryParams) { - for (const key of Object.getOwnPropertyNames(this.customQueryParams)) { - params = params.set(key, this.customQueryParams[key]); - } - } - - const headers = new HttpHeaders().set( - 'Content-Type', - 'application/x-www-form-urlencoded' - ); + if (!this.useHttpBasicAuthForAuthorizationCodeFlow) { + params = params.set('client_id', this.clientId); + } + if (this.customQueryParams) { + for (const key of Object.getOwnPropertyNames(this.customQueryParams)) { + params = params.set(key, this.customQueryParams[key]); + } + } + let headers = new HttpHeaders().set( + 'Content-Type', + 'application/x-www-form-urlencoded'); + + if (this.useHttpBasicAuthForAuthorizationCodeFlow) { + headers = headers.append( + 'Accept', + 'application/json'); + const header = btoa(`${this.clientId}:${this.dummyClientSecret}`); + headers = headers.append( + 'Authorization', + 'BASIC ' + header); + } this.http.post(this.tokenEndpoint, params, { headers }).subscribe( (tokenResponse) => { @@ -738,7 +759,7 @@ export class OAuthService extends AuthConfig { this.storeAccessTokenResponse(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenResponse.scope); if (this.oidc && tokenResponse.id_token) { - this.processIdToken(tokenResponse.id_token, tokenResponse.access_token). + this.processIdTokenState(tokenResponse.id_token, tokenResponse.access_token, state). then(result => { this.storeIdToken(result); @@ -770,6 +791,63 @@ export class OAuthService extends AuthConfig { }); } + private fetchToken(params: HttpParams): Promise { + + if (!this.validateUrlForHttps(this.tokenEndpoint)) { + throw new Error( + 'tokenEndpoint must use Http. Also check property requireHttps.' + ); + } + + return new Promise((resolve, reject) => { + params = params.set('client_id', this.clientId); + if (this.customQueryParams) { + for (const key of Object.getOwnPropertyNames(this.customQueryParams)) { + params = params.set(key, this.customQueryParams[key]); + } + } + let headers = new HttpHeaders().set( + 'Content-Type', + 'application/x-www-form-urlencoded'); + + this.http.post(this.tokenEndpoint, params, { headers }).subscribe( + (tokenResponse) => { + this.debug('refresh tokenResponse', tokenResponse); + this.storeAccessTokenResponse(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenResponse.scope); + + if (this.oidc && tokenResponse.id_token) { + this.processIdToken(tokenResponse.id_token, tokenResponse.access_token). + then(result => { + this.storeIdToken(result); + + this.eventsSubject.next(new OAuthSuccessEvent('token_received')); + this.eventsSubject.next(new OAuthSuccessEvent('token_refreshed')); + + resolve(tokenResponse); + }) + .catch(reason => { + this.eventsSubject.next(new OAuthErrorEvent('token_validation_error', reason)); + console.error('Error validating tokens'); + console.error(reason); + + reject(reason); + }); + } else { + this.eventsSubject.next(new OAuthSuccessEvent('token_received')); + this.eventsSubject.next(new OAuthSuccessEvent('token_refreshed')); + + resolve(tokenResponse); + } + }, + (err) => { + console.error('Error getting token', err); + this.eventsSubject.next(new OAuthErrorEvent('token_refresh_error', err)); + reject(err); + } + ); + }); + } + private removeSilentRefreshEventListener(): void { if (this.silentRefreshPostMessageEventListener) { window.removeEventListener( @@ -1330,10 +1408,12 @@ export class OAuthService extends AuthConfig { let parameter = window.location.search.split("?")[1].split("&"); let codeParam = parameter.filter(param => param.includes('code=')); let code = codeParam.length ? codeParam[0].split('code=')[1] : undefined; + let stateParam = parameter.filter(param => param.includes('state=')); + let state = stateParam.length ? stateParam[0].split('state=')[1] : undefined; if (code) { return new Promise((resolve, reject) => { - this.getTokenFromCode(code).then(result => { + this.getTokenFromCode(code,state).then(result => { resolve(); }).catch(err => { reject(err); @@ -1353,7 +1433,7 @@ export class OAuthService extends AuthConfig { * @param options Optinal options. */ private tryLoginImplicit(options: LoginOptions = null): Promise { - options = options || {}; + options = options || {}; let parts: object; @@ -1657,6 +1737,137 @@ export class OAuthService extends AuthConfig { }); } + /** + * @ignore + */ + public processIdTokenState( + idToken: string, + accessToken: string, + state: string + ): Promise { + const tokenParts = idToken.split('.'); + const headerBase64 = this.padBase64(tokenParts[0]); + const headerJson = b64DecodeUnicode(headerBase64); + const header = JSON.parse(headerJson); + const claimsBase64 = this.padBase64(tokenParts[1]); + const claimsJson = b64DecodeUnicode(claimsBase64); + const claims = JSON.parse(claimsJson); + const savedNonce = this._storage.getItem('nonce'); + + if (Array.isArray(claims.aud)) { + if (claims.aud.every(v => v !== this.clientId)) { + const err = 'Wrong audience: ' + claims.aud.join(','); + console.warn(err); + return Promise.reject(err); + } + } else { + if (claims.aud !== this.clientId) { + const err = 'Wrong audience: ' + claims.aud; + console.warn(err); + return Promise.reject(err); + } + } + + if (!claims.sub) { + const err = 'No sub claim in id_token'; + console.warn(err); + return Promise.reject(err); + } + + if ( + this.sessionChecksEnabled && + this.silentRefreshSubject && + this.silentRefreshSubject !== claims['sub'] + ) { + const err = + 'After refreshing, we got an id_token for another user (sub). ' + + `Expected sub: ${this.silentRefreshSubject}, received sub: ${ + claims['sub'] + }`; + + console.warn(err); + return Promise.reject(err); + } + + if (!claims.iat) { + const err = 'No iat claim in id_token'; + console.warn(err); + return Promise.reject(err); + } + + if (claims.iss !== this.issuer) { + const err = 'Wrong issuer: ' + claims.iss; + console.warn(err); + return Promise.reject(err); + } + + if (!this.disableNonceCheck && state !== savedNonce) { + const err = 'Wrong nonce: ' + state; + console.warn(err); + return Promise.reject(err); + } + + if ( + !this.disableAtHashCheck && + this.requestAccessToken && + !claims['at_hash'] + ) { + const err = 'An at_hash is needed!'; + console.warn(err); + return Promise.reject(err); + } + + const now = Date.now(); + const issuedAtMSec = claims.iat * 1000; + const expiresAtMSec = claims.exp * 1000; + const tenMinutesInMsec = 1000 * 60 * 10; + + if ( + issuedAtMSec - tenMinutesInMsec >= now || + expiresAtMSec + tenMinutesInMsec <= now + ) { + const err = 'Token has been expired'; + console.error(err); + console.error({ + now: now, + issuedAtMSec: issuedAtMSec, + expiresAtMSec: expiresAtMSec + }); + return Promise.reject(err); + } + + const validationParams: ValidationParams = { + accessToken: accessToken, + idToken: idToken, + jwks: this.jwks, + idTokenClaims: claims, + idTokenHeader: header, + loadKeys: () => this.loadJwks() + }; + + if ( + !this.disableAtHashCheck && + this.requestAccessToken && + !this.checkAtHash(validationParams) + ) { + const err = 'Wrong at_hash'; + console.warn(err); + return Promise.reject(err); + } + + return this.checkSignature(validationParams).then(_ => { + const result: ParsedIdToken = { + idToken: idToken, + idTokenClaims: claims, + idTokenClaimsJson: claimsJson, + idTokenHeader: header, + idTokenHeaderJson: headerJson, + idTokenExpiresAt: expiresAtMSec + }; + return result; + }); + } + /** * Returns the received claims about the user. */ From 1af2e6f371d6aa92f18724503f71a3e1bc032d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20Carlos=20Branda=CC=83o?= Date: Tue, 19 Feb 2019 14:59:33 +0000 Subject: [PATCH 2/2] refactored code after reading PR #3 --- projects/lib/src/oauth-service.ts | 216 +----------------------------- 1 file changed, 7 insertions(+), 209 deletions(-) diff --git a/projects/lib/src/oauth-service.ts b/projects/lib/src/oauth-service.ts index 061c2f86..920d3d16 100644 --- a/projects/lib/src/oauth-service.ts +++ b/projects/lib/src/oauth-service.ts @@ -699,30 +699,18 @@ export class OAuthService extends AuthConfig { return this.fetchToken(params); } - public refreshTokenUsingState(state: string): Promise { - - let params = new HttpParams() - .set('grant_type', 'refresh_token') - .set('refresh_token', this._storage.getItem('refresh_token')) - .set('scope', this.scope); - if (this.dummyClientSecret) { - params = params.set('client_secret', this.dummyClientSecret); - } - return this.fetchTokenUsingState(params,state); - } - /** * Get token using an intermediate code. Works for the Authorization Code flow. */ - private getTokenFromCode(code: string,state: string): Promise { + private getTokenFromCode(code: string): Promise { let params = new HttpParams() .set('grant_type', 'authorization_code') .set('code', code) .set('redirect_uri', this.redirectUri); - return this.fetchTokenUsingState(params,state); + return this.fetchToken(params); } - private fetchTokenUsingState(params: HttpParams,state: string): Promise { + private fetchToken(params: HttpParams): Promise { if (!this.validateUrlForHttps(this.tokenEndpoint)) { throw new Error( @@ -759,7 +747,7 @@ export class OAuthService extends AuthConfig { this.storeAccessTokenResponse(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenResponse.scope); if (this.oidc && tokenResponse.id_token) { - this.processIdTokenState(tokenResponse.id_token, tokenResponse.access_token, state). + this.processIdToken(tokenResponse.id_token, tokenResponse.access_token). then(result => { this.storeIdToken(result); @@ -791,63 +779,6 @@ export class OAuthService extends AuthConfig { }); } - private fetchToken(params: HttpParams): Promise { - - if (!this.validateUrlForHttps(this.tokenEndpoint)) { - throw new Error( - 'tokenEndpoint must use Http. Also check property requireHttps.' - ); - } - - return new Promise((resolve, reject) => { - params = params.set('client_id', this.clientId); - if (this.customQueryParams) { - for (const key of Object.getOwnPropertyNames(this.customQueryParams)) { - params = params.set(key, this.customQueryParams[key]); - } - } - let headers = new HttpHeaders().set( - 'Content-Type', - 'application/x-www-form-urlencoded'); - - this.http.post(this.tokenEndpoint, params, { headers }).subscribe( - (tokenResponse) => { - this.debug('refresh tokenResponse', tokenResponse); - this.storeAccessTokenResponse(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenResponse.scope); - - if (this.oidc && tokenResponse.id_token) { - this.processIdToken(tokenResponse.id_token, tokenResponse.access_token). - then(result => { - this.storeIdToken(result); - - this.eventsSubject.next(new OAuthSuccessEvent('token_received')); - this.eventsSubject.next(new OAuthSuccessEvent('token_refreshed')); - - resolve(tokenResponse); - }) - .catch(reason => { - this.eventsSubject.next(new OAuthErrorEvent('token_validation_error', reason)); - console.error('Error validating tokens'); - console.error(reason); - - reject(reason); - }); - } else { - this.eventsSubject.next(new OAuthSuccessEvent('token_received')); - this.eventsSubject.next(new OAuthSuccessEvent('token_refreshed')); - - resolve(tokenResponse); - } - }, - (err) => { - console.error('Error getting token', err); - this.eventsSubject.next(new OAuthErrorEvent('token_refresh_error', err)); - reject(err); - } - ); - }); - } - private removeSilentRefreshEventListener(): void { if (this.silentRefreshPostMessageEventListener) { window.removeEventListener( @@ -1171,7 +1102,7 @@ export class OAuthService extends AuthConfig { let nonce = null; if (!this.disableNonceCheck) { - let nonce = this.createAndSaveNonce(); + nonce = this.createAndSaveNonce(); if (state) { state = nonce + this.config.nonceStateSeparator + state; } else { @@ -1408,12 +1339,10 @@ export class OAuthService extends AuthConfig { let parameter = window.location.search.split("?")[1].split("&"); let codeParam = parameter.filter(param => param.includes('code=')); let code = codeParam.length ? codeParam[0].split('code=')[1] : undefined; - let stateParam = parameter.filter(param => param.includes('state=')); - let state = stateParam.length ? stateParam[0].split('state=')[1] : undefined; if (code) { return new Promise((resolve, reject) => { - this.getTokenFromCode(code,state).then(result => { + this.getTokenFromCode(code).then(result => { resolve(); }).catch(err => { reject(err); @@ -1737,137 +1666,6 @@ export class OAuthService extends AuthConfig { }); } - /** - * @ignore - */ - public processIdTokenState( - idToken: string, - accessToken: string, - state: string - ): Promise { - const tokenParts = idToken.split('.'); - const headerBase64 = this.padBase64(tokenParts[0]); - const headerJson = b64DecodeUnicode(headerBase64); - const header = JSON.parse(headerJson); - const claimsBase64 = this.padBase64(tokenParts[1]); - const claimsJson = b64DecodeUnicode(claimsBase64); - const claims = JSON.parse(claimsJson); - const savedNonce = this._storage.getItem('nonce'); - - if (Array.isArray(claims.aud)) { - if (claims.aud.every(v => v !== this.clientId)) { - const err = 'Wrong audience: ' + claims.aud.join(','); - console.warn(err); - return Promise.reject(err); - } - } else { - if (claims.aud !== this.clientId) { - const err = 'Wrong audience: ' + claims.aud; - console.warn(err); - return Promise.reject(err); - } - } - - if (!claims.sub) { - const err = 'No sub claim in id_token'; - console.warn(err); - return Promise.reject(err); - } - - if ( - this.sessionChecksEnabled && - this.silentRefreshSubject && - this.silentRefreshSubject !== claims['sub'] - ) { - const err = - 'After refreshing, we got an id_token for another user (sub). ' + - `Expected sub: ${this.silentRefreshSubject}, received sub: ${ - claims['sub'] - }`; - - console.warn(err); - return Promise.reject(err); - } - - if (!claims.iat) { - const err = 'No iat claim in id_token'; - console.warn(err); - return Promise.reject(err); - } - - if (claims.iss !== this.issuer) { - const err = 'Wrong issuer: ' + claims.iss; - console.warn(err); - return Promise.reject(err); - } - - if (!this.disableNonceCheck && state !== savedNonce) { - const err = 'Wrong nonce: ' + state; - console.warn(err); - return Promise.reject(err); - } - - if ( - !this.disableAtHashCheck && - this.requestAccessToken && - !claims['at_hash'] - ) { - const err = 'An at_hash is needed!'; - console.warn(err); - return Promise.reject(err); - } - - const now = Date.now(); - const issuedAtMSec = claims.iat * 1000; - const expiresAtMSec = claims.exp * 1000; - const tenMinutesInMsec = 1000 * 60 * 10; - - if ( - issuedAtMSec - tenMinutesInMsec >= now || - expiresAtMSec + tenMinutesInMsec <= now - ) { - const err = 'Token has been expired'; - console.error(err); - console.error({ - now: now, - issuedAtMSec: issuedAtMSec, - expiresAtMSec: expiresAtMSec - }); - return Promise.reject(err); - } - - const validationParams: ValidationParams = { - accessToken: accessToken, - idToken: idToken, - jwks: this.jwks, - idTokenClaims: claims, - idTokenHeader: header, - loadKeys: () => this.loadJwks() - }; - - if ( - !this.disableAtHashCheck && - this.requestAccessToken && - !this.checkAtHash(validationParams) - ) { - const err = 'Wrong at_hash'; - console.warn(err); - return Promise.reject(err); - } - - return this.checkSignature(validationParams).then(_ => { - const result: ParsedIdToken = { - idToken: idToken, - idTokenClaims: claims, - idTokenClaimsJson: claimsJson, - idTokenHeader: header, - idTokenHeaderJson: headerJson, - idTokenExpiresAt: expiresAtMSec - }; - return result; - }); - } - /** * Returns the received claims about the user. */ @@ -2120,4 +1918,4 @@ export class OAuthService extends AuthConfig { } return this.tokenValidationHandler.validateSignature(params); } -} +} \ No newline at end of file