JENKINS-35783# JWT support (#392)
* JENKINS-35783# JWT support
* TOC update
* Fix for failure during permission check
Added missing GrantedAuthorities to JwtAuthenticationToken.
* Curl wrapper to call APIs with JWT token
* Initial frontend commit for JWT
* Add braces to a function call
* Refactor some http into core-js
* Major refactoring of frontend code to use JWT
* Fix lint issues
* Add missing files
* Set core-js to version 0.0.1-beta1
* Bump verison of core-js
* Fix tests and lints
* Bump versions
* Fix linting
* Add some documentation
* Refactor core-js
* Bump core-js version
* Recommit files I renamed
* Bump core-js version
* Update SSE bus with correct function calls
* Added docs to invoke API in browser with JWT enabled.
* Fixes for PR comments
* Add utils.js
* Bump core-js version
* Remove console.log
* Make token set anonymous user corrently
* Revert "Make token set anonymous user corrently"
This reverts commit e95bc044dd
.
* Polyfill es6-promise for Promises in actions.js
* commit
* New js-builder version with fix for deduped modules
* Another new js-builder version with change to require search transform
* Bump core-js version
* Fix linting
* Fix for anonymous user authentication
* Starting a run had missing authorization check
* Bump JWT vesion
* Relase version of core-js
* Fix double blue in urls
* Fix linting
* Fix tests
This commit is contained in:
commit
7b58c5f582
|
@ -0,0 +1,83 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
##
|
||||
#
|
||||
# Usage
|
||||
#
|
||||
# jwtcurl [-u username:password] [-b BASE_URL] "[-X GET|POST|PUT|DELETE] BO_API_URL"
|
||||
#
|
||||
# Options:
|
||||
# -v: verbose output
|
||||
# -u: basic auth parameter in username:password format
|
||||
# -b: base url of jenkins without trailing slash. e.g. http://localhost:8080/jenkins or https://blueocean.io
|
||||
#
|
||||
# Note: You need to enclose last argument in double quotes if you are passing arguments to curl.
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# Anonymous user:
|
||||
#
|
||||
# jwtcurl http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/p1/
|
||||
#
|
||||
# User with credentials:
|
||||
#
|
||||
# jwtcurl -u admin:admin http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/p1/
|
||||
#
|
||||
# Use base url other than http://localhost:8080/jenkins
|
||||
#
|
||||
# jwtcurl -u admin:admin -b https://myjenkinshost http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/p1/
|
||||
#
|
||||
# Author: Vivek Pandey
|
||||
#
|
||||
##
|
||||
if [ $# -eq 0 ]
|
||||
then
|
||||
echo "Usage: jwtcurl [-v] [-u username:password] [-b BASE_URL] \"-X [GET|POST|PUT|DELETE] BO_API_URL\""
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
while [[ $# -gt 1 ]]
|
||||
do
|
||||
key="$1"
|
||||
|
||||
case $key in
|
||||
-u)
|
||||
CREDENTIAL="-u $2"
|
||||
shift
|
||||
;;
|
||||
-b)
|
||||
BASE_URL="$2"
|
||||
shift
|
||||
;;
|
||||
-v)
|
||||
VERBOSE="$2"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
# unknown option
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ ! -z "$VERBOSE" ]; then
|
||||
SETX="set -x"
|
||||
CURL_VERBOSE="-v"
|
||||
fi
|
||||
|
||||
if [ -z "${BASE_URL}" ]; then
|
||||
BASE_URL=http://localhost:8080/jenkins
|
||||
fi
|
||||
|
||||
${SETX}
|
||||
|
||||
TOKEN=$(curl ${CURL_VERBOSE} -s -X GET ${CREDENTIAL} -I ${BASE_URL}/jwt-auth/token | awk 'BEGIN {FS=": "}/^X-BLUEOCEAN-JWT/{print $2}'|sed $'s/\r//')
|
||||
|
||||
if [ -z "${TOKEN}" ]; then
|
||||
echo "Failed to get JWT token"
|
||||
echo $?
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl ${CURL_VERBOSE} -H "Authorization: Bearer ${TOKEN}" $@
|
||||
|
|
@ -1,53 +1,8 @@
|
|||
{
|
||||
"ecmaFeatures": {
|
||||
"jsx": true,
|
||||
"modules": true
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "babel-eslint",
|
||||
"extends": "@jenkins-cd/jenkins/react",
|
||||
"rules": {
|
||||
"eol-last": 1,
|
||||
"no-unused-vars": [1],
|
||||
"max-len": [1, 160, 4],
|
||||
"no-underscore-dangle": [0],
|
||||
"object-shorthand": [0, "always"],
|
||||
"quote-props": [0, "as-needed"],
|
||||
"no-var": 0,
|
||||
"prefer-const": 2,
|
||||
"prefer-arrow-callback": 2,
|
||||
"no-trailing-spaces": [0, { "skipBlankLines": true }],
|
||||
"key-spacing": [2],
|
||||
"semi": [2],
|
||||
"no-extra-semi": 2,
|
||||
"no-console": 1,
|
||||
"prefer-template": 1,
|
||||
"react/display-name": 0,
|
||||
"react/jsx-boolean-value": 2,
|
||||
"jsx-quotes": 2,
|
||||
"react/jsx-no-undef": 2,
|
||||
"react/jsx-sort-props": 0,
|
||||
"react/jsx-uses-react": 2,
|
||||
"react/jsx-uses-vars": 2,
|
||||
"react/no-did-mount-set-state": 2,
|
||||
"react/no-did-update-set-state": 2,
|
||||
"react/no-multi-comp": 0,
|
||||
"react/no-unknown-property": 2,
|
||||
"react/prop-types": 2,
|
||||
"react/react-in-jsx-scope": 2,
|
||||
"react/self-closing-comp": 2,
|
||||
"react/wrap-multilines": 2,
|
||||
"quotes": [0, "single"],
|
||||
"space-before-function-paren": [2, {
|
||||
"anonymous": "always",
|
||||
"named": "never"
|
||||
}],
|
||||
"strict": [2, "global"]
|
||||
},
|
||||
"plugins": [
|
||||
"react"
|
||||
]
|
||||
"react/jsx-no-bind": 0,
|
||||
"no-unused-vars": [2, {"varsIgnorePattern": "^React$"}],
|
||||
"max-len": [1, 160, 4]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"name": "@jenkins-cd/blueocean-core-js",
|
||||
"version": "0.0.1-beta5",
|
||||
"version": "0.0.4",
|
||||
"description": "Shared JavaScript libraries for use with Jenkins Blue Ocean",
|
||||
"main": "dist/js/index.js",
|
||||
"scripts": {
|
||||
"gulp": "gulp",
|
||||
"test": "gulp test"
|
||||
"test": "gulp test",
|
||||
"prepublish": "gulp build"
|
||||
},
|
||||
"author": "Cliff Meyers <cmeyers@cloudbees.com> (https://www.cloudbees.com/)",
|
||||
"contributors": [
|
||||
|
@ -23,7 +24,10 @@
|
|||
"url": "https://github.com/jenkinsci/blueocean-plugin.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"es6-promise": "3.2.1",
|
||||
"isomorphic-fetch": "2.2.1",
|
||||
"jsonwebtoken": "7.1.9",
|
||||
"pem-jwk": "1.5.1",
|
||||
"mobx": "2.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
@ -31,6 +35,8 @@
|
|||
"react-dom": "^15.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jenkins-cd/eslint-config-jenkins": "0.0.2",
|
||||
"eslint-plugin-react": "^5.0.1",
|
||||
"babel-eslint": "6.0.5",
|
||||
"babel-plugin-transform-decorators-legacy": "1.3.4",
|
||||
"babel-preset-es2015": "6.9.0",
|
||||
|
@ -40,7 +46,6 @@
|
|||
"browserify": "13.0.1",
|
||||
"chai": "3.5.0",
|
||||
"enzyme": "2.4.1",
|
||||
"eslint-plugin-react": "5.2.2",
|
||||
"gulp": "3.9.1",
|
||||
"gulp-babel": "6.1.2",
|
||||
"gulp-clean": "0.3.2",
|
||||
|
|
|
@ -0,0 +1,179 @@
|
|||
import es6Promise from 'es6-promise'; es6Promise.polyfill();
|
||||
import jwt from './jwt';
|
||||
import isoFetch from 'isomorphic-fetch';
|
||||
import utils from './utils.js';
|
||||
|
||||
export const FetchFunctions = {
|
||||
/**
|
||||
* This method checks for for 2XX http codes. Throws error it it is not.
|
||||
* This should only be used if not using fetch or fetchJson.
|
||||
*/
|
||||
checkStatus(response) {
|
||||
if (response.status >= 300 || response.status < 200) {
|
||||
const error = new Error(response.statusText);
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds same-origin option to the fetch.
|
||||
*/
|
||||
sameOriginFetchOption(options = {}) {
|
||||
const newOpts = utils.clone(options);
|
||||
newOpts.credentials = newOpts.credentials || 'same-origin';
|
||||
return newOpts;
|
||||
},
|
||||
|
||||
/**
|
||||
* Enhances the fetchOptions with the JWT bearer token. Will only be needed
|
||||
* if not using fetch or fetchJson.
|
||||
*/
|
||||
jwtFetchOption(token, options = {}) {
|
||||
const newOpts = utils.clone(options);
|
||||
newOpts.headers = newOpts.headers || {};
|
||||
newOpts.headers.Authorization = newOpts.headers.Authorization || `Bearer ${token}`;
|
||||
return newOpts;
|
||||
},
|
||||
|
||||
/**
|
||||
* REturns the json body from the response. It is only needed if
|
||||
* you are using FetchUtils.fetch
|
||||
*
|
||||
* Usage:
|
||||
* FetchUtils.fetch(..).then(FetchUtils.parseJSON)
|
||||
*/
|
||||
parseJSON(response) {
|
||||
return response.json()
|
||||
// FIXME: workaround for status=200 w/ empty response body that causes error in Chrome
|
||||
// server should probably return HTTP 204 instead
|
||||
.catch((error) => {
|
||||
if (error.message === 'Unexpected end of JSON input') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Error function helper to log errors to console.
|
||||
*
|
||||
* Usage;
|
||||
* fetchJson(..).catch(FetchUtils.consoleError)
|
||||
*/
|
||||
consoleError(error) {
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
},
|
||||
|
||||
/**
|
||||
* Error function helper to call a callback on a rejected promise.
|
||||
* if callback is null, log to console). Use .catch() if you know it
|
||||
* will not be null though.
|
||||
*
|
||||
* Usage;
|
||||
* fetchJson(..).catch(FetchUtils.onError(error => //do something)
|
||||
*/
|
||||
onError(errorFunc) {
|
||||
return error => {
|
||||
if (errorFunc) {
|
||||
errorFunc(error);
|
||||
} else {
|
||||
FetchFunctions.consoleError(error);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Raw fetch that returns the json body.
|
||||
*
|
||||
* This method is semi-private, under normal conditions it should not be
|
||||
* used as it does not include the JWT bearer token
|
||||
*
|
||||
* @param {string} url - The URL to fetch from.
|
||||
* @param {Object} [options]
|
||||
* @param {function} [options.onSuccess] - Optional callback success function.
|
||||
* @param {function} [options.onError] - Optional error callback.
|
||||
* @param {Object} [options.fetchOptions] - Optional isomorphic-fetch options.
|
||||
* @returns JSON body
|
||||
*/
|
||||
rawFetchJSON(url, { onSuccess, onError, fetchOptions } = {}) {
|
||||
const request = isoFetch(url, fetchOptions)
|
||||
.then(FetchFunctions.checkStatus)
|
||||
.then(FetchFunctions.parseJSON);
|
||||
|
||||
if (onSuccess) {
|
||||
return request.then(onSuccess).catch(FetchFunctions.onError(onError));
|
||||
}
|
||||
|
||||
return request;
|
||||
},
|
||||
/**
|
||||
* Raw fetch.
|
||||
*
|
||||
* This method is semi-private, under normal conditions it should not be
|
||||
* used as it does not include the JWT bearer token
|
||||
*
|
||||
* @param {string} url - The URL to fetch from.
|
||||
* @param {Object} [options]
|
||||
* @param {function} [options.onSuccess] - Optional callback success function.
|
||||
* @param {function} [options.onError] - Optional error callback.
|
||||
* @param {Object} [options.fetchOptions] - Optional isomorphic-fetch options.
|
||||
* @returns fetch response
|
||||
*/
|
||||
rawFetch(url, { onSuccess, onError, fetchOptions } = {}) {
|
||||
const request = isoFetch(url, fetchOptions)
|
||||
.then(FetchFunctions.checkStatus);
|
||||
|
||||
if (onSuccess) {
|
||||
return request.then(onSuccess).catch(FetchFunctions.onError(onError));
|
||||
}
|
||||
|
||||
return request;
|
||||
},
|
||||
};
|
||||
|
||||
export const Fetch = {
|
||||
/**
|
||||
* Fetch JSON data.
|
||||
* <p>
|
||||
* Utility function that can be mocked for testing.
|
||||
*
|
||||
* @param {string} url - The URL to fetch from.
|
||||
* @param {Object} [options]
|
||||
* @param {function} [options.onSuccess] - Optional callback success function.
|
||||
* @param {function} [options.onError] - Optional error callback.
|
||||
* @param {Object} [options.fetchOptions] - Optional isomorphic-fetch options.
|
||||
* @returns JSON body.
|
||||
*/
|
||||
fetchJSON(url, { onSuccess, onError, fetchOptions } = {}) {
|
||||
return jwt.getToken()
|
||||
.then(token => FetchFunctions.rawFetchJSON(url, {
|
||||
onSuccess,
|
||||
onError,
|
||||
fetchOptions: FetchFunctions.jwtFetchOption(token, fetchOptions),
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch data.
|
||||
* <p>
|
||||
* Utility function that can be mocked for testing.
|
||||
*
|
||||
* @param {string} url - The URL to fetch from.
|
||||
* @param {Object} [options]
|
||||
* @param {function} [options.onSuccess] - Optional callback success function.
|
||||
* @param {function} [options.onError] - Optional error callback.
|
||||
* @param {Object} [options.fetchOptions] - Optional isomorphic-fetch options.
|
||||
* @returns fetch body.
|
||||
*/
|
||||
fetch(url, { onSuccess, onError, fetchOptions } = {}) {
|
||||
return jwt.getToken()
|
||||
.then(token => FetchFunctions.rawFetch(url, {
|
||||
onSuccess,
|
||||
onError,
|
||||
fetchOptions: FetchFunctions.jwtFetchOption(token, fetchOptions),
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
|
@ -1,7 +1,14 @@
|
|||
|
||||
export { Fetch, FetchFunctions } from './fetch';
|
||||
export UrlConfig from './urlconfig';
|
||||
export JWT from './jwt';
|
||||
export TestUtils from './testutils';
|
||||
export Utils from './utils';
|
||||
|
||||
/**
|
||||
* Created by cmeyers on 8/18/16.
|
||||
*/
|
||||
import { ToastService} from './ToastService';
|
||||
import { ToastService } from './ToastService';
|
||||
|
||||
// export ToastService as a singleton so all plugins will use the same instance
|
||||
// otherwise toasts from plugins will not be displayed in blueocean-web UI
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
import es6Promise from 'es6-promise'; es6Promise.polyfill();
|
||||
import fetch from 'isomorphic-fetch';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import UrlUtils from './urlconfig';
|
||||
import { FetchFunctions } from './fetch';
|
||||
import { jwk2pem } from 'pem-jwk';
|
||||
let storedToken = null;
|
||||
let publicKeyStore = null;
|
||||
let tokenFetchPromise = null;
|
||||
export default {
|
||||
/**
|
||||
* Fetches the JWT token. This token is cached for a default of 25mins.
|
||||
* If it is within 5mins or expiry it will fetch a new one.
|
||||
*/
|
||||
fetchJWT() {
|
||||
if (storedToken && storedToken.exp) {
|
||||
const diff = storedToken.exp - Math.trunc(new Date().getTime() / 1000);
|
||||
if (diff < 300) {
|
||||
tokenFetchPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenFetchPromise) {
|
||||
tokenFetchPromise = fetch(`${UrlUtils.getJenkinsRootURL()}/jwt-auth/token`, { credentials: 'same-origin' })
|
||||
.then(this.checkStatus)
|
||||
.then(response => {
|
||||
const token = response.headers.get('X-BLUEOCEAN-JWT');
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
|
||||
throw new Error('Could not fetch jwt_token');
|
||||
});
|
||||
}
|
||||
|
||||
return tokenFetchPromise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies the token using the public key.
|
||||
*/
|
||||
verifyToken(token, certObject) {
|
||||
return new Promise((resolve, reject) =>
|
||||
jwt.verify(token, jwk2pem(certObject), { algorithms: [certObject.alg] }, (err, payload) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(payload);
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches the public key that is used to verify tokens.
|
||||
*/
|
||||
fetchJWTPublicKey(token) {
|
||||
const decoded = jwt.decode(token, { complete: true });
|
||||
const url = `${UrlUtils.getJenkinsRootURL()}/jwt-auth/jwks/${decoded.header.kid}/`;
|
||||
if (!publicKeyStore) {
|
||||
publicKeyStore = fetch(url, { credentials: 'same-origin' })
|
||||
.then(FetchFunctions.checkStatus)
|
||||
.then(FetchFunctions.parseJSON)
|
||||
.then(cert => this.verifyToken(token, cert)
|
||||
.then(payload => ({
|
||||
token,
|
||||
payload,
|
||||
})));
|
||||
}
|
||||
|
||||
return publicKeyStore;
|
||||
},
|
||||
|
||||
/**
|
||||
* Puts the token into global storage for later use.
|
||||
*/
|
||||
storeToken(data) {
|
||||
storedToken = data.payload;
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Use this function if you want the payload from the token.
|
||||
*/
|
||||
getTokenWithPayload() {
|
||||
return this.fetchJWT()
|
||||
.then(FetchFunctions.checkStatus)
|
||||
.then(token => this.fetchJWTPublicKey(token))
|
||||
.then(data => this.storeToken(data));
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the token from te server and verifies it.
|
||||
*/
|
||||
getToken() {
|
||||
return this.getTokenWithPayload().then(token => token.token);
|
||||
},
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
import { Fetch, FetchFunctions } from './fetch';
|
||||
|
||||
// default impls
|
||||
const fetchJSON = Fetch.fetchJSON;
|
||||
const fetch = Fetch.fetch;
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Switches fetch functions for ones that dont use JWT. Needed
|
||||
* for running tests.
|
||||
*/
|
||||
patchFetchNoJWT() {
|
||||
Fetch.fetchJSON = FetchFunctions.rawFetchJSON;
|
||||
Fetch.fetch = FetchFunctions.rawFetch;
|
||||
},
|
||||
|
||||
/**
|
||||
* Restores original fetch functions.
|
||||
*/
|
||||
restoreFetch() {
|
||||
Fetch.fetchJSON = fetchJSON;
|
||||
Fetch.fetch = fetch;
|
||||
},
|
||||
|
||||
/**
|
||||
* Patches fetch functions with a resolved promise. This will make all fetch calls return
|
||||
* this data.
|
||||
*
|
||||
* Usage
|
||||
*
|
||||
* TestUtils.patchFetchWithData((url, options) => {
|
||||
* assert.equals(url,"someurl")
|
||||
* return { mydata: 5 }
|
||||
* })
|
||||
*/
|
||||
patchFetchWithData(dataFn) {
|
||||
Fetch.fetchJSON = Fetch.fetch = (url, options) => {
|
||||
const { onSuccess, onError } = options || {};
|
||||
|
||||
const data = Promise.resolve(dataFn(url, options));
|
||||
|
||||
if (onSuccess) {
|
||||
return data.then(onSuccess).catch(FetchFunctions.onError(onError));
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
},
|
||||
};
|
|
@ -0,0 +1,40 @@
|
|||
let blueOceanAppURL = '/';
|
||||
let jenkinsRootURL = '';
|
||||
|
||||
let loaded = false;
|
||||
|
||||
function loadConfig() {
|
||||
try {
|
||||
const headElement = document.getElementsByTagName('head')[0];
|
||||
|
||||
// Look up where the Blue Ocean app is hosted
|
||||
blueOceanAppURL = headElement.getAttribute('data-appurl');
|
||||
if (typeof blueOceanAppURL !== 'string') {
|
||||
blueOceanAppURL = '/';
|
||||
}
|
||||
|
||||
jenkinsRootURL = headElement.getAttribute('data-rooturl');
|
||||
loaded = true;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('error reading attributes from document; urls will be empty');
|
||||
|
||||
loaded = false;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getJenkinsRootURL() {
|
||||
if (!loaded) {
|
||||
loadConfig();
|
||||
}
|
||||
return jenkinsRootURL;
|
||||
},
|
||||
|
||||
getBlueOceanAppURL() {
|
||||
if (!loaded) {
|
||||
loadConfig();
|
||||
}
|
||||
return blueOceanAppURL;
|
||||
},
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
|
||||
export default {
|
||||
clone(obj) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
},
|
||||
};
|
|
@ -35,14 +35,14 @@
|
|||
"skin-deep": "^0.16.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jenkins-cd/blueocean-core-js": "0.0.1-beta5",
|
||||
"@jenkins-cd/blueocean-core-js": "0.0.4",
|
||||
"@jenkins-cd/design-language": "0.0.70",
|
||||
"@jenkins-cd/js-extensions": "0.0.22",
|
||||
"@jenkins-cd/js-modules": "0.0.6",
|
||||
"@jenkins-cd/sse-gateway": "0.0.7",
|
||||
"es6-promise": "3.2.1",
|
||||
"immutable": "3.8.1",
|
||||
"isomorphic-fetch": "2.2.1",
|
||||
"es6-promise": "3.2.1",
|
||||
"keymirror": "0.1.1",
|
||||
"moment": "2.13.0",
|
||||
"moment-duration-format": "1.3.0",
|
||||
|
@ -60,7 +60,6 @@
|
|||
"import": [
|
||||
"@jenkins-cd/sse-gateway",
|
||||
"immutable",
|
||||
"isomorphic-fetch",
|
||||
"keymirror",
|
||||
"react-redux",
|
||||
"react-router",
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import * as sse from '@jenkins-cd/sse-gateway';
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import appConfig from './config';
|
||||
|
||||
import { UrlConfig } from '@jenkins-cd/blueocean-core-js';
|
||||
const { object, node } = PropTypes;
|
||||
|
||||
appConfig.loadConfig();
|
||||
|
||||
// Connect to the SSE Gateway and allocate a
|
||||
// dispatcher for blueocean.
|
||||
|
@ -12,7 +10,7 @@ appConfig.loadConfig();
|
|||
sse.connect({
|
||||
clientId: 'jenkins_blueocean',
|
||||
onConnect: undefined,
|
||||
jenkinsUrl: `${appConfig.getJenkinsRootURL()}/`, // FIXME sse should not require this to end with a /
|
||||
jenkinsUrl: `${UrlConfig.getJenkinsRootURL()}/`, // FIXME sse should not require this to end with a /
|
||||
});
|
||||
|
||||
class Dashboard extends Component {
|
||||
|
|
|
@ -4,9 +4,7 @@
|
|||
* Non-react component that contains general API methods for
|
||||
* interacting with pipelines, encapsulating REST API calls etc.
|
||||
*/
|
||||
|
||||
import fetch from 'isomorphic-fetch';
|
||||
import config from '../config';
|
||||
import { Fetch, UrlConfig } from '@jenkins-cd/blueocean-core-js';
|
||||
import * as urlUtils from '../util/UrlUtils';
|
||||
import * as sse from '@jenkins-cd/sse-gateway';
|
||||
import assert from 'assert';
|
||||
|
@ -47,25 +45,18 @@ export default class Pipeline {
|
|||
}
|
||||
|
||||
restUrl() {
|
||||
return `${config.blueoceanAppURL}${this.url}`;
|
||||
return `${UrlConfig.getBlueOceanAppURL()}${this.url}`;
|
||||
}
|
||||
|
||||
run(onSuccess, onFail) {
|
||||
run(onSuccess, onError) {
|
||||
const url = `${this.restUrl()}/runs/`;
|
||||
|
||||
fetch(url, {
|
||||
const fetchOptions = {
|
||||
method: 'post',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((response) => {
|
||||
if (onSuccess && response.status >= 200 && response.status < 300) {
|
||||
onSuccess(response);
|
||||
} else if (onFail && (response.status < 200 || response.status > 299)) {
|
||||
onFail(response);
|
||||
}
|
||||
});
|
||||
};
|
||||
Fetch.fetch(url, { fetchOptions, onSuccess, onError });
|
||||
}
|
||||
|
||||
onJobChannelEvent(callback) {
|
||||
|
|
|
@ -1,12 +1,11 @@
|
|||
import keymirror from 'keymirror';
|
||||
import fetch from 'isomorphic-fetch';
|
||||
import { fetch as smartFetch, paginate, applyFetchMarkers } from '../util/smart-fetch';
|
||||
import { State } from '../components/records';
|
||||
import UrlConfig from '../config';
|
||||
import { getNodesInformation } from '../util/logDisplayHelper';
|
||||
import { calculateStepsBaseUrl, calculateLogUrl, calculateNodeBaseUrl, paginateUrl, getRestUrl } from '../util/UrlUtils';
|
||||
import findAndUpdate from '../util/find-and-update';
|
||||
|
||||
import { Fetch, FetchFunctions } from '@jenkins-cd/blueocean-core-js';
|
||||
const debugLog = require('debug')('blueocean-actions-js:debug');
|
||||
|
||||
/**
|
||||
|
@ -156,21 +155,6 @@ export const actionHandlers = {
|
|||
},
|
||||
};
|
||||
|
||||
// fetch helper
|
||||
const fetchOptions = { credentials: 'same-origin' };
|
||||
function checkStatus(response) {
|
||||
if (response.status >= 300 || response.status < 200) {
|
||||
const error = new Error(response.statusText);
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(response) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function parseMoreDataHeader(response) {
|
||||
let newStart = null;
|
||||
/*
|
||||
|
@ -187,28 +171,6 @@ function parseMoreDataHeader(response) {
|
|||
const payload = { response, newStart };
|
||||
return payload;
|
||||
}
|
||||
/**
|
||||
* Fetch JSON data.
|
||||
* <p>
|
||||
* Utility function that can be mocked for testing.
|
||||
*
|
||||
* @param url The URL to fetch from.
|
||||
* @param onSuccess o
|
||||
* @param onError
|
||||
*/
|
||||
exports.fetchJson = function fetchJson(url, onSuccess, onError) {
|
||||
return fetch(url, fetchOptions)
|
||||
.then(checkStatus)
|
||||
.then(parseJSON)
|
||||
.then(onSuccess)
|
||||
.catch((error) => {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
} else {
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch TXT/log data and inject a start parameter to indicate that a refetch is needed
|
||||
|
@ -228,17 +190,10 @@ exports.fetchLogsInjectStart = function fetchLogsInjectStart(url, start, onSucce
|
|||
} else {
|
||||
refetchUrl = `${url}?start=${start}`;
|
||||
}
|
||||
return fetch(refetchUrl, fetchOptions)
|
||||
.then(checkStatus)
|
||||
return Fetch.fetch(refetchUrl)
|
||||
.then(parseMoreDataHeader)
|
||||
.then(onSuccess)
|
||||
.catch((error) => {
|
||||
if (onError) {
|
||||
onError(error);
|
||||
} else {
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
}
|
||||
});
|
||||
.catch(FetchFunctions.onError(onError));
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -425,7 +380,7 @@ export const actions = {
|
|||
};
|
||||
},
|
||||
|
||||
updateRunState(event, config) {
|
||||
updateRunState(event) {
|
||||
function matchesEvent(evt, o) {
|
||||
return o.job_run_queueId === evt.job_run_queueId
|
||||
|| (isRun(o) && o.id === evt.jenkins_object_id
|
||||
|
@ -442,7 +397,7 @@ export const actions = {
|
|||
});
|
||||
if (found) {
|
||||
debugLog('Calling dispatch for event ', event);
|
||||
const runUrl = `${config.getAppURLBase()}${event.blueocean_job_rest_url}/runs/${event.jenkins_object_id}`;
|
||||
const runUrl = `${UrlConfig.getJenkinsRootURL()}${event.blueocean_job_rest_url}runs/${event.jenkins_object_id}`;
|
||||
smartFetch(runUrl)
|
||||
.then(data => {
|
||||
if (data.$pending) return;
|
||||
|
@ -493,7 +448,7 @@ export const actions = {
|
|||
}
|
||||
});
|
||||
if (found) {
|
||||
const url = `${UrlConfig.getJenkinsRootURL()}/blue${event.blueocean_job_rest_url}`;
|
||||
const url = `${UrlConfig.getJenkinsRootURL()}${event.blueocean_job_rest_url}`;
|
||||
smartFetch(url, (branchData) => {
|
||||
if (branchData.$pending) { return; }
|
||||
if (branchData.$failure) {
|
||||
|
@ -578,28 +533,6 @@ export const actions = {
|
|||
};
|
||||
},
|
||||
|
||||
generateData(url, actionType, optional) {
|
||||
return (dispatch) => fetch(url, fetchOptions)
|
||||
.then(checkStatus)
|
||||
.then(parseJSON)
|
||||
.then(json => dispatch({
|
||||
...optional,
|
||||
type: actionType,
|
||||
payload: json,
|
||||
}))
|
||||
.catch((error) => {
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
dispatch({
|
||||
payload: { type: 'ERROR', message: `${error.stack}` },
|
||||
type: ACTION_TYPES.UPDATE_MESSAGES,
|
||||
});
|
||||
// call again with no payload so actions handle missing data
|
||||
dispatch({
|
||||
...optional,
|
||||
type: actionType,
|
||||
});
|
||||
});
|
||||
},
|
||||
/*
|
||||
For the detail view we need to fetch the different nodes of
|
||||
a run in case we do not have specific node, to
|
||||
|
@ -637,9 +570,8 @@ export const actions = {
|
|||
}
|
||||
|
||||
if (!data || !data[nodesBaseUrl] || config.refetch) {
|
||||
return exports.fetchJson(
|
||||
nodesBaseUrl,
|
||||
(json) => {
|
||||
return Fetch.fetchJSON(nodesBaseUrl)
|
||||
.then((json) => {
|
||||
const information = getNodesInformation(json);
|
||||
information.nodesBaseUrl = nodesBaseUrl;
|
||||
dispatch({
|
||||
|
@ -648,10 +580,9 @@ export const actions = {
|
|||
});
|
||||
|
||||
return getNodeAndSteps(information);
|
||||
},
|
||||
(error) => console.error('error', error) // eslint-disable-line no-console
|
||||
);
|
||||
}).catch(FetchFunctions.consoleError);
|
||||
}
|
||||
|
||||
return getNodeAndSteps(data[nodesBaseUrl]);
|
||||
};
|
||||
},
|
||||
|
@ -687,18 +618,15 @@ export const actions = {
|
|||
const data = getState().adminStore.steps;
|
||||
const stepBaseUrl = calculateStepsBaseUrl(config);
|
||||
if (!data || !data[stepBaseUrl] || config.refetch) {
|
||||
return exports.fetchJson(
|
||||
stepBaseUrl,
|
||||
(json) => {
|
||||
const information = getNodesInformation(json);
|
||||
information.nodesBaseUrl = stepBaseUrl;
|
||||
return dispatch({
|
||||
type: ACTION_TYPES.SET_STEPS,
|
||||
payload: information,
|
||||
});
|
||||
},
|
||||
(error) => console.error('error', error) // eslint-disable-line no-console
|
||||
);
|
||||
return Fetch.fetchJSON(stepBaseUrl)
|
||||
.then((json) => {
|
||||
const information = getNodesInformation(json);
|
||||
information.nodesBaseUrl = stepBaseUrl;
|
||||
return dispatch({
|
||||
type: ACTION_TYPES.SET_STEPS,
|
||||
payload: information,
|
||||
});
|
||||
}).catch(FetchFunctions.consoleError);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
const infoLog = require('debug')('smart-fetch:info');
|
||||
const debugLog = require('debug')('smart-fetch:debug');
|
||||
const errorLog = require('debug')('smart-fetch:error');
|
||||
import isoFetch from 'isomorphic-fetch';
|
||||
import dedupe from './dedupe-calls';
|
||||
import { Fetch } from '@jenkins-cd/blueocean-core-js';
|
||||
|
||||
/**
|
||||
* How many records to fetch by default
|
||||
|
@ -25,36 +24,6 @@ const deepFreeze = (obj) => {
|
|||
return Object.freeze(obj);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the status is 200-299 or returns an error
|
||||
*/
|
||||
const checkStatus = (response) => {
|
||||
if (response.status >= 300 || response.status < 200) {
|
||||
errorLog('ERROR: ', response);
|
||||
const error = new Error(response.statusText);
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the JSON response body
|
||||
*/
|
||||
const parseJSON = (rsp) => {
|
||||
try {
|
||||
return rsp.json();
|
||||
} catch (err) {
|
||||
errorLog('Unable to parse JSON: ', rsp.body, err);
|
||||
throw new Error('Invalid JSON payload', err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Static fetch options used for every request
|
||||
*/
|
||||
const fetchOptions = { credentials: 'same-origin' };
|
||||
|
||||
/**
|
||||
* Mark with $success flag and freeze the object
|
||||
*/
|
||||
|
@ -82,19 +51,16 @@ export function fetch(url, options, onData) {
|
|||
debugLog(' -- pending: ', url);
|
||||
_onData({ $pending: true });
|
||||
return dedupe(url, () =>
|
||||
isoFetch(url, _options || fetchOptions) // Fetch data
|
||||
.then(checkStatus) // Validate success
|
||||
.then(parseJSON) // transfer to json
|
||||
.then(successAndFreeze) // add success field & freeze graph
|
||||
)
|
||||
.then((data) => {
|
||||
debugLog(' -- success: ', url, data);
|
||||
_onData(data);
|
||||
})
|
||||
.catch(err => {
|
||||
debugLog(' -- error: ', url, err);
|
||||
_onData({ $failed: err });
|
||||
});
|
||||
Fetch.fetchJSON(url, { fetchOptions: _options || {} }) // Fetch data
|
||||
.then(successAndFreeze)) // add success field & freeze graph
|
||||
.then((data) => {
|
||||
debugLog(' -- success: ', url, data);
|
||||
_onData(data);
|
||||
})
|
||||
.catch(err => {
|
||||
debugLog(' -- error: ', url, err);
|
||||
_onData({ $failed: err });
|
||||
});
|
||||
}
|
||||
// return a fake promise, a thenable
|
||||
// so it can be resolved multiple times
|
||||
|
@ -169,11 +135,8 @@ class Pager {
|
|||
onData(assignObj(concatenator(this, existingData), { $pending: true, $pager: this }));
|
||||
infoLog('Fetching paged data: ', this);
|
||||
return dedupe(url, () =>
|
||||
isoFetch(url, fetchOptions) // Fetch data
|
||||
.then(checkStatus) // Validate success
|
||||
.then(parseJSON) // transfer to json
|
||||
.then(successAndFreeze) // add success field & freeze graph
|
||||
)
|
||||
Fetch.fetchJSON(url) // Fetch data
|
||||
.then(successAndFreeze)) // add success field & freeze graph
|
||||
.then((data) => {
|
||||
debugLog(' -- success: ', url, data);
|
||||
// fetched an extra to test if more
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import React from 'react';
|
||||
import {assert} from 'chai';
|
||||
import {shallow} from 'enzyme';
|
||||
import { assert } from 'chai';
|
||||
import { shallow } from 'enzyme';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import nock from 'nock';
|
||||
import { TestUtils } from '@jenkins-cd/blueocean-core-js';
|
||||
TestUtils.patchFetchNoJWT();
|
||||
|
||||
import {
|
||||
actions,
|
||||
|
@ -11,12 +13,12 @@ import {
|
|||
steps as stepsSelector,
|
||||
} from '../../main/js/redux';
|
||||
|
||||
import {runNodesSuccess, runNodesFail, runNodesRunning} from './runNodes';
|
||||
import {firstFinishedSecondRunning} from './runNodes-firstFinishedSecondRunning';
|
||||
import {firstRunning} from './runNodes-firstRunning';
|
||||
import {finishedMultipleFailure} from './runNodes-finishedMultipleFailure';
|
||||
import {queuedAborted} from './runNodes-QueuedAborted';
|
||||
import {getNodesInformation} from './../../main/js/util/logDisplayHelper';
|
||||
import { runNodesSuccess, runNodesFail, runNodesRunning } from './runNodes';
|
||||
import { firstFinishedSecondRunning } from './runNodes-firstFinishedSecondRunning';
|
||||
import { firstRunning } from './runNodes-firstRunning';
|
||||
import { finishedMultipleFailure } from './runNodes-finishedMultipleFailure';
|
||||
import { queuedAborted } from './runNodes-QueuedAborted';
|
||||
import { getNodesInformation } from './../../main/js/util/logDisplayHelper';
|
||||
|
||||
|
||||
import Step from '../../main/js/components/Step';
|
||||
|
@ -79,7 +81,7 @@ describe("React component test of different runs", () => {
|
|||
assert.isNotNull(wrapper);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
describe("LogStore should work", () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll()
|
||||
|
@ -117,5 +119,6 @@ Tue May 24 13:42:38 CEST 2016
|
|||
});
|
||||
|
||||
});
|
||||
*/
|
||||
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import nock from 'nock';
|
|||
import mockFetch from './util/smart-fetch-mock';
|
||||
|
||||
import * as actions from '../../main/js/redux/actions';
|
||||
import { TestUtils } from '@jenkins-cd/blueocean-core-js';
|
||||
import findAndUpdate from '../../main/js/util/find-and-update';
|
||||
|
||||
const debugLog = require('debug')('push-events-actions:debug');
|
||||
|
@ -15,7 +16,7 @@ function newEvent(type) {
|
|||
blueocean_is_for_current_job: true,
|
||||
job_ismultibranch: 'true',
|
||||
blueocean_job_pipeline_name: "PR-demo",
|
||||
blueocean_job_rest_url: '/rest/organizations/jenkins/pipelines/PR-demo/branches/quicker',
|
||||
blueocean_job_rest_url: '/rest/organizations/jenkins/pipelines/PR-demo/branches/quicker/',
|
||||
jenkins_channel: "job",
|
||||
jenkins_event: type,
|
||||
jenkins_object_name: "CloudBeers/PR-demo/quicker",
|
||||
|
@ -30,12 +31,9 @@ const CONFIG = {
|
|||
getAppURLBase: function() { return '/jenkins'; }
|
||||
};
|
||||
|
||||
|
||||
const originalFetchJson = actions.fetchJson;
|
||||
|
||||
describe("push events - queued run tests", () => {
|
||||
afterEach(() => {
|
||||
actions.fetchJson = originalFetchJson;
|
||||
TestUtils.restoreFetch();
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
|
@ -153,7 +151,7 @@ describe("push events - queued run tests", () => {
|
|||
|
||||
describe("push events - started run tests", () => {
|
||||
afterEach(() => {
|
||||
actions.fetchJson = originalFetchJson;
|
||||
TestUtils.restoreFetch();
|
||||
});
|
||||
|
||||
// Test run started event for when the event is for the pipeline that
|
||||
|
@ -198,7 +196,7 @@ describe("push events - started run tests", () => {
|
|||
|
||||
const dispatcher = actions.actions.updateRunState(event, CONFIG, true);
|
||||
|
||||
dispatcher(function (actualDispatchObj) {
|
||||
dispatcher(actualDispatchObj => {
|
||||
debugLog('dispatch type: ', actualDispatchObj.type, 'with payload:', actualDispatchObj.payload);
|
||||
if (actualDispatchObj.type == 'FIND_AND_UPDATE') {
|
||||
debugLog('findAndUpdate: ', adminStore, ' with payload: ', actualDispatchObj.payload);
|
||||
|
@ -209,7 +207,7 @@ describe("push events - started run tests", () => {
|
|||
adminStore.runs['PR-demo'] = actualDispatchObj.payload;
|
||||
}
|
||||
}
|
||||
}, function () {
|
||||
}, () => {
|
||||
return {
|
||||
adminStore: adminStore
|
||||
}
|
||||
|
@ -218,6 +216,7 @@ describe("push events - started run tests", () => {
|
|||
|
||||
// Fire the start event and then check that the run state
|
||||
// has changed as expected.
|
||||
|
||||
fireEvent();
|
||||
|
||||
var runs = adminStore.runs['PR-demo'];
|
||||
|
@ -261,6 +260,7 @@ describe("push events - started run tests", () => {
|
|||
adminStore.runs['PR-demo'] = actualDispatchObj.payload;
|
||||
}
|
||||
}
|
||||
|
||||
}, function () {
|
||||
return {
|
||||
adminStore: adminStore
|
||||
|
@ -271,13 +271,13 @@ describe("push events - started run tests", () => {
|
|||
// Fire the start event and then check that the run state
|
||||
// has changed as expected .
|
||||
fireEvent();
|
||||
|
||||
var runs = adminStore.runs['PR-demo'];
|
||||
const runs = adminStore.runs['PR-demo'];
|
||||
assert.equal(runs.length, 1);
|
||||
// This time, the run state should have changed as expected
|
||||
// because we do it manually when the fetch fails, but we don't
|
||||
// see the time changes etc.
|
||||
assert.equal(runs[0].enQueueTime, undefined);
|
||||
assert.equal(runs[0].state, 'RUNNING');
|
||||
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,8 +2,12 @@ import { assert } from 'chai';
|
|||
import nock from 'nock';
|
||||
import { fetch, paginate } from '../../main/js/util/smart-fetch';
|
||||
const debug = require('debug')('smart-fetch-test:debug');
|
||||
import { TestUtils, Fetch, FetchFunctions } from '@jenkins-cd/blueocean-core-js';
|
||||
|
||||
describe("smart-fetch", () => {
|
||||
beforeEach(() => {
|
||||
TestUtils.patchFetchNoJWT();
|
||||
})
|
||||
afterEach(() => {
|
||||
nock.cleanAll()
|
||||
});
|
||||
|
@ -47,6 +51,7 @@ describe("smart-fetch", () => {
|
|||
done();
|
||||
return;
|
||||
}
|
||||
console.log("blad");
|
||||
// will get here first
|
||||
assert(data.$pending);
|
||||
});
|
||||
|
|
|
@ -1,19 +1,20 @@
|
|||
import React from 'react';
|
||||
import {assert} from 'chai';
|
||||
import { assert } from 'chai';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import nock from 'nock';
|
||||
|
||||
import {getNodesInformation} from './../../main/js/util/logDisplayHelper';
|
||||
import { getNodesInformation } from './../../main/js/util/logDisplayHelper';
|
||||
import {
|
||||
actions,
|
||||
} from '../../main/js/redux';
|
||||
import {nodes, stepsNode45} from './nodes';
|
||||
import { nodes, stepsNode45 } from './nodes';
|
||||
|
||||
import {
|
||||
calculateRunLogURLObject, calculateStepsBaseUrl, calculateNodeBaseUrl,
|
||||
} from '../../main/js/util/UrlUtils';
|
||||
|
||||
import { TestUtils } from '@jenkins-cd/blueocean-core-js';
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureMockStore(middlewares);
|
||||
|
@ -43,6 +44,7 @@ Tue May 24 13:42:38 CEST 2016
|
|||
`);
|
||||
const store = mockStore({adminStore: {logs: {}}});
|
||||
logGeneral.url = `http://example.com${logGeneral.url}`;
|
||||
TestUtils.patchFetchNoJWT();
|
||||
return store.dispatch(
|
||||
actions.fetchLog({...logGeneral}))
|
||||
.then(() => { // return of async actions
|
||||
|
@ -72,9 +74,10 @@ Tue May 24 13:42:38 CEST 2016
|
|||
.reply(200, stepsNode45)
|
||||
;
|
||||
const store = mockStore({adminStore: {}});
|
||||
mergedConfig._appURLBase = `${baseUrl}:80`;
|
||||
mergedConfig._appURLBase = `${baseUrl}`;
|
||||
TestUtils.patchFetchNoJWT();
|
||||
|
||||
store.dispatch(
|
||||
return store.dispatch(
|
||||
actions.fetchSteps({...mergedConfig, node}))
|
||||
.then(() => { // return of async actions
|
||||
assert.equal(store.getActions()[0].type, 'SET_STEPS');
|
||||
|
@ -82,8 +85,9 @@ Tue May 24 13:42:38 CEST 2016
|
|||
assert.equal(store.getActions()[0].payload.isError, false);
|
||||
assert.equal(store.getActions()[0].payload.nodesBaseUrl, `${baseUrl}${stepsUrl}`);
|
||||
assert.equal(store.getActions()[0].payload.model.length, 10);
|
||||
});
|
||||
assert.equal(stepsNock.isDone(), true);
|
||||
assert.equal(stepsNock.isDone(), true);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -111,6 +115,8 @@ Tue May 24 13:42:38 CEST 2016
|
|||
const steps = {};
|
||||
steps[`${baseUrl}:80${stepsUrl}`] = getNodesInformation(stepsNode45);
|
||||
const otherStore = mockStore({adminStore: {steps}});
|
||||
TestUtils.patchFetchNoJWT();
|
||||
|
||||
otherStore.dispatch(actions.fetchNodes(mergedConfig));
|
||||
assert.equal(nodeNock.isDone(), true);
|
||||
});
|
||||
|
|
|
@ -18,17 +18,20 @@ import { latestRuns } from './data/runs/latestRuns';
|
|||
import job_crud_created_multibranch from './data/sse/job_crud_created_multibranch';
|
||||
import fetchedBranches from './data/branches/latestBranches';
|
||||
|
||||
import { Fetch, TestUtils } from '@jenkins-cd/blueocean-core-js';
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureMockStore(middlewares);
|
||||
|
||||
import * as actionsModule from '../../main/js/redux/actions';
|
||||
const actionsFetch = actionsModule.fetchJson;
|
||||
const actionsFetch = Fetch.fetchJSON;
|
||||
|
||||
describe("Redux Store - ", () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
actionsModule.fetchJson = actionsFetch;
|
||||
Fetch.fetchJSON = actionsFetch;
|
||||
});
|
||||
|
||||
/* TODO: Fix this test
|
||||
it("create store with pipeline data", () => {
|
||||
var ruleId = '/rest/organizations/jenkins/pipelines/';
|
||||
nock('http://example.com')
|
||||
|
@ -43,7 +46,8 @@ describe("Redux Store - ", () => {
|
|||
assert.equal(store.getActions()[0].payload.length, pipelines.length);
|
||||
assert.equal(pipelinesSelector({adminStore: {allPipelines: pipelines}}).length, pipelines.length);
|
||||
});
|
||||
});
|
||||
});*/
|
||||
|
||||
it("create store with branch data", () => {
|
||||
var ruleId = '/rest/organizations/jenkins/pipelines/xxx/runs/';
|
||||
var baseUrl = 'http://example.com';
|
||||
|
@ -85,8 +89,7 @@ describe("Redux Store - ", () => {
|
|||
});
|
||||
|
||||
const dispatches = [];
|
||||
|
||||
actionFunc((dispatchConfig) => {
|
||||
const ret = actionFunc((dispatchConfig) => {
|
||||
dispatches.push(dispatchConfig);
|
||||
}, () => {
|
||||
// fetchedBranches is a 3 branch array. First 2 branches
|
||||
|
@ -104,11 +107,6 @@ describe("Redux Store - ", () => {
|
|||
}
|
||||
};
|
||||
});
|
||||
|
||||
//console.log('------------------');
|
||||
//console.log(dispatches);
|
||||
//console.log('------------------');
|
||||
|
||||
return dispatches;
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License
|
||||
|
||||
Copyright (c) 2016 CloudBees Inc and a number of other of contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
|
@ -0,0 +1,88 @@
|
|||
# BlueOcean JWT Plugin
|
||||
|
||||
This plugin provides JWT authenticated related APIs. JWT token is signed using RSA256 algorithm. This is asymmetric
|
||||
algorithm, this means the token is signed using the private key and Client must use corresponding public key to verify
|
||||
the claims.
|
||||
|
||||
# APIs
|
||||
|
||||
## JWT Token API
|
||||
|
||||
JWT token is generated for the user in session. In Jenkins there is always a user in context, that is if there is no
|
||||
logged in user then the generated token will carry the claim for anonymous user.
|
||||
|
||||
Default expiry time of token is 30 minutes.
|
||||
|
||||
JWT token is return as X-BLUEOCEAN-JWT HTTP header.
|
||||
|
||||
|
||||
GET /jwt-auth/token
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
X-BLUEOCEAN-JWT: eyJraWQiOiI2M2ZhMTY0ZWRhMDk0NjNjOGZlZTI2Njg4ZjgxOTZmZCIsImFsZyI6IlJTMjU2IiwidHlwIjoiSldUIn0.eyJqdGkiOiJiMGVmMjJiNDliNWM0N2JjODU4YTg2MDdkM2Y0NGQzMyIsImlzcyI6ImJsdWVvY2Vhbi1qd3Q6Iiwic3ViIjoiYWxpY2UiLCJuYW1lIjoiQWxpY2UgQ29vcGVyIiwiaWF0IjoxNDcwMzMxNjA1LCJleHAiOjE0NzAzMzM0MDUsIm5iZiI6MTQ3MDMzMTU3NSwiY29udGV4dCI6eyJ1c2VyIjp7ImlkIjoiYWxpY2UiLCJmdWxsTmFtZSI6IkFsaWNlIENvb3BlciIsImVtYWlsIjoiYWxpY2VAamVua2lucy1jaS5vcmcifX19.H1iZAR2ajMeWRhh1VDdbqOtD7Wo0e0FZx8JDDNzphLu2DaLlxVRzBbhZ5TllvPx787kbNeK2tymFu_2Y_59qkq7YxZkrJctZTeiHVlTlHIxf2woBBggkIgoSvzNSsCcX73vjH5A5e54T5e8rUjF56XP05d5-WDvvheLo_Sqn4j19_lXkogCC2-JhDfc7sb8Xnw5PwYNZs29JYSSLOuUWm8UnD3AnBeFBhPfY2bR8-BjPXxdRWAyrZ-bz1CITfOm1xHZ-8NCGsfsUUGlcB_ijPVBt5T_29JWWFnougM1qZ_CEO56xu1572LMUmBYi8ynl75frzoSL_PvZYMXF47zcdg
|
||||
|
||||
JSON presentation of this token:
|
||||
|
||||
Header:
|
||||
|
||||
{"kid":"63fa164eda09463c8fee26688f8196fd","alg":"RS256","typ":"JWT"}
|
||||
|
||||
Claims:
|
||||
|
||||
{
|
||||
"name" : "Alice Cooper",
|
||||
"iss" : "blueocean-jwt:",
|
||||
"sub" : "alice",
|
||||
"exp" : 1470333405,
|
||||
"nbf" : 1470331575,
|
||||
"context" : {
|
||||
"user" : {
|
||||
"id" : "alice",
|
||||
"fullName" : "Alice Cooper",
|
||||
"email" : "alice@jenkins-ci.org"
|
||||
}
|
||||
},
|
||||
"jti" : "b0ef22b49b5c47bc858a8607d3f44d33",
|
||||
"iat" : 1470331605
|
||||
}
|
||||
|
||||
### Change expiry time
|
||||
|
||||
JWT tokens expires after 30 minutes (Default). exp claim header gives the time at which token expires. It is unix time
|
||||
in seconds. Default 30 minutes can be changed by sending expiryTimeInMins query parameter. This parameter value must be
|
||||
less than maximum expiry time allowed (8 hours or 480 minutes).
|
||||
|
||||
This parameter must be used carefully, it has security implications.
|
||||
|
||||
GET /jwt-auth/token?expiryTimeInMins=15
|
||||
|
||||
## Change maximum allowed expiry time
|
||||
|
||||
Use query maxExpiryTimeInMins to change default 8 hours maximum allowed expiry time.
|
||||
|
||||
This parameter must be used carefully, it has security implications.
|
||||
|
||||
GET /jwt-auth/token?maxExpiryTimeInMins=15
|
||||
|
||||
## Json web key (jwk) API
|
||||
|
||||
Client can call this API to get public key using the key id received as part of JWT header field 'kid'. This public key
|
||||
must be used to verify the JWT token.
|
||||
|
||||
GET /jwt-auth/jwks/bab71d7b184548a6b93480721d352ba1
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-type: application/json
|
||||
{
|
||||
"alg" : "RS256",
|
||||
"e" : "AQAB",
|
||||
"kty" : "RSA",
|
||||
"n" : "AMmWNNrmWzJXik7K7gmDkPumxqPzxc/JnxWsZ3CrhJGSO8hIgfsN6M5UHWSwkAoBHyNIaaPXhubWpcWCRewiI0U2Aw4jO3vzxNndRB9YaDPrrWDjvKBaqMC08IePPxmxXCj3ZS0QoEpf6rczdm2f9Of6Fro0TufXf2EYjLndBH7ep6iDQ4/TG7FkD7o39/GXuHAin0sz7atrPun3tlkuxllu5XNV+yW6WusrNIz3txyvKKEyQX950eW/6mMD0hS6yT7TbAwfrxkTnq4SiagCTllV+ct4wfnONDrao3WYgZnNgohsX/nEnYMHYq592n2WZW/i2+PNaFZlL2+3QgWO4qc=",
|
||||
"use" : "sig",
|
||||
"key_ops" : [
|
||||
"verify"
|
||||
],
|
||||
"kid" : "bab71d7b184548a6b93480721d352ba1"
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>io.jenkins.blueocean</groupId>
|
||||
<artifactId>blueocean-parent</artifactId>
|
||||
<version>1.0-alpha-10-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>blueocean-jwt</artifactId>
|
||||
<packaging>hpi</packaging>
|
||||
|
||||
<name>BlueOcean :: JWT module</name>
|
||||
<url>https://wiki.jenkins-ci.org/display/JENKINS/Blue+Ocean+Plugin</url>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.bitbucket.b_c</groupId>
|
||||
<artifactId>jose4j</artifactId>
|
||||
<version>0.5.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>blueocean-commons</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.jenkins-ci.plugins</groupId>
|
||||
<artifactId>mailer</artifactId>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
</project>
|
|
@ -0,0 +1,21 @@
|
|||
package io.jenkins.blueocean.auth.jwt;
|
||||
|
||||
import net.sf.json.JSONObject;
|
||||
import org.kohsuke.stapler.WebMethod;
|
||||
|
||||
/**
|
||||
* Issuer of JSON Web Key.
|
||||
*
|
||||
* @author Kohsuke Kawaguchi
|
||||
* @author Vivek Pandey
|
||||
* @see JwtAuthenticationService#getJwks(String)
|
||||
*/
|
||||
public abstract class JwkService {
|
||||
|
||||
/**
|
||||
*
|
||||
* @return Gives JWK JSONObject
|
||||
*/
|
||||
@WebMethod(name = "")
|
||||
public abstract JSONObject getJwk();
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package io.jenkins.blueocean.auth.jwt;
|
||||
|
||||
import hudson.ExtensionPoint;
|
||||
import hudson.model.RootAction;
|
||||
import org.kohsuke.stapler.QueryParameter;
|
||||
import org.kohsuke.stapler.WebMethod;
|
||||
import org.kohsuke.stapler.verb.GET;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* JWT endpoint resource. Provides functionality to get JWT token and also provides JWK endpoint to get
|
||||
* public key using keyId.
|
||||
*
|
||||
* @author Vivek Pandey
|
||||
*/
|
||||
public abstract class JwtAuthenticationService implements RootAction, ExtensionPoint{
|
||||
|
||||
@Override
|
||||
public String getUrlName() {
|
||||
return "jwt-auth";
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gives JWT token for authenticated user. See https://tools.ietf.org/html/rfc7519.
|
||||
*
|
||||
* @param expiryTimeInMins token expiry time. Default 30 min.
|
||||
* @param maxExpiryTimeInMins max token expiry time. Default expiry time is 8 hours (480 mins)
|
||||
*
|
||||
* @return JWT if there is authenticated user or if anonymous user has at least READ permission, otherwise 401
|
||||
* error code is returned
|
||||
*
|
||||
* @see JwtToken
|
||||
*/
|
||||
@GET
|
||||
@WebMethod(name = "token")
|
||||
public abstract JwtToken getToken(@Nullable @QueryParameter("expiryTimeInMins") Integer expiryTimeInMins,
|
||||
@Nullable @QueryParameter("maxExpiryTimeInMins") Integer maxExpiryTimeInMins);
|
||||
|
||||
/**
|
||||
* Gives Json web key. See https://tools.ietf.org/html/rfc7517
|
||||
*
|
||||
* @param keyId keyId of the key
|
||||
*
|
||||
* @return JWK reponse
|
||||
*/
|
||||
@GET
|
||||
public abstract JwkService getJwks(String keyId);
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
package io.jenkins.blueocean.auth.jwt;
|
||||
|
||||
import io.jenkins.blueocean.commons.ServiceException;
|
||||
import jenkins.security.RSADigitalSignatureConfidentialKey;
|
||||
import net.sf.json.JSONObject;
|
||||
import org.jose4j.jws.AlgorithmIdentifiers;
|
||||
import org.jose4j.jws.JsonWebSignature;
|
||||
import org.jose4j.jwx.HeaderParameterNames;
|
||||
import org.jose4j.lang.JoseException;
|
||||
import org.kohsuke.stapler.HttpResponse;
|
||||
import org.kohsuke.stapler.StaplerRequest;
|
||||
import org.kohsuke.stapler.StaplerResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.servlet.ServletException;
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* JWT token
|
||||
*
|
||||
* Generates JWT token
|
||||
*
|
||||
* @author Vivek Pandey
|
||||
*/
|
||||
public final class JwtToken implements HttpResponse{
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtToken.class);
|
||||
|
||||
public static final String X_BLUEOCEAN_JWT="X-BLUEOCEAN-JWT";
|
||||
private static final String DEFAULT_KEY_ID = UUID.randomUUID().toString().replace("-", "");
|
||||
|
||||
/**
|
||||
* JWT header
|
||||
*/
|
||||
public final JSONObject header = new JSONObject();
|
||||
|
||||
|
||||
/**
|
||||
* JWT Claim
|
||||
*/
|
||||
public final JSONObject claim = new JSONObject();
|
||||
|
||||
|
||||
/**
|
||||
* Generates base64 representation of JWT token sign using "RS256" algorithm
|
||||
*
|
||||
* getHeader().toBase64UrlEncode() + "." + getClaim().toBase64UrlEncode() + "." + sign
|
||||
*
|
||||
*
|
||||
* @return base64 representation of JWT token
|
||||
*/
|
||||
public String sign(){
|
||||
for(JwtTokenDecorator decorator: JwtTokenDecorator.all()){
|
||||
decorator.decorate(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* kid might have been set already by using {@link #header} or {@link JwtTokenDecorator}, if present use it
|
||||
* otherwise use the default kid
|
||||
*/
|
||||
String keyId = (String)header.get(HeaderParameterNames.KEY_ID);
|
||||
if(keyId == null){
|
||||
keyId = DEFAULT_KEY_ID;
|
||||
}
|
||||
|
||||
JwtRsaDigitalSignatureKey rsaDigitalSignatureConfidentialKey = new JwtRsaDigitalSignatureKey(keyId);
|
||||
|
||||
try {
|
||||
return rsaDigitalSignatureConfidentialKey.sign(claim);
|
||||
} catch (JoseException e) {
|
||||
String msg = "Failed to sign JWT token: "+e.getMessage();
|
||||
logger.error(msg);
|
||||
throw new ServiceException.UnexpectedErrorException(msg, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException {
|
||||
rsp.setStatus(200);
|
||||
rsp.addHeader(X_BLUEOCEAN_JWT, sign());
|
||||
}
|
||||
|
||||
public final static class JwtRsaDigitalSignatureKey extends RSADigitalSignatureConfidentialKey{
|
||||
private final String id;
|
||||
|
||||
public JwtRsaDigitalSignatureKey(String id) {
|
||||
super("blueoceanJwt-"+id);
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String sign(JSONObject claim) throws JoseException {
|
||||
JsonWebSignature jsonWebSignature = new JsonWebSignature();
|
||||
jsonWebSignature.setPayload(claim.toString());
|
||||
jsonWebSignature.setKey(getPrivateKey());
|
||||
jsonWebSignature.setKeyIdHeaderValue(id);
|
||||
jsonWebSignature.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
|
||||
jsonWebSignature.setHeader(HeaderParameterNames.TYPE, "JWT");
|
||||
|
||||
return jsonWebSignature.getCompactSerialization();
|
||||
}
|
||||
|
||||
public boolean exists() throws IOException {
|
||||
return super.load()!=null;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package io.jenkins.blueocean.auth.jwt;
|
||||
|
||||
import hudson.ExtensionList;
|
||||
import hudson.ExtensionPoint;
|
||||
|
||||
/**
|
||||
* Participates in the creation of JwtToken
|
||||
*
|
||||
* @author Vivek Pandey
|
||||
*/
|
||||
public abstract class JwtTokenDecorator implements ExtensionPoint {
|
||||
|
||||
|
||||
/** Decorates {@link JwtToken}
|
||||
*
|
||||
* @param token token to be decorated
|
||||
*
|
||||
* @return returns decorated token
|
||||
*/
|
||||
public abstract JwtToken decorate(JwtToken token);
|
||||
|
||||
/**
|
||||
* Returns all the registered {@link JwtTokenDecorator}s
|
||||
*/
|
||||
public static ExtensionList<JwtTokenDecorator> all() {
|
||||
return ExtensionList.lookup(JwtTokenDecorator.class);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
package io.jenkins.blueocean.auth.jwt.impl;
|
||||
|
||||
import com.google.common.collect.ImmutableList;
|
||||
import hudson.Extension;
|
||||
import hudson.Plugin;
|
||||
import hudson.model.User;
|
||||
import hudson.remoting.Base64;
|
||||
import hudson.tasks.Mailer;
|
||||
import io.jenkins.blueocean.auth.jwt.JwkService;
|
||||
import io.jenkins.blueocean.auth.jwt.JwtAuthenticationService;
|
||||
import io.jenkins.blueocean.auth.jwt.JwtToken;
|
||||
import io.jenkins.blueocean.commons.ServiceException;
|
||||
import jenkins.model.Jenkins;
|
||||
import net.sf.json.JSONObject;
|
||||
import org.acegisecurity.Authentication;
|
||||
import org.kohsuke.stapler.QueryParameter;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author Vivek Pandey
|
||||
*/
|
||||
@Extension
|
||||
public class JwtImpl extends JwtAuthenticationService {
|
||||
|
||||
private static int DEFAULT_EXPIRY_IN_SEC = 1800;
|
||||
private static int DEFAULT_MAX_EXPIRY_TIME_IN_MIN = 480;
|
||||
private static int DEFAULT_NOT_BEFORE_IN_SEC = 30;
|
||||
|
||||
@Override
|
||||
public JwtToken getToken(@Nullable @QueryParameter("expiryTimeInMins") Integer expiryTimeInMins, @Nullable @QueryParameter("maxExpiryTimeInMins") Integer maxExpiryTimeInMins) {
|
||||
String t = System.getProperty("EXPIRY_TIME_IN_MINS");
|
||||
long expiryTime=DEFAULT_EXPIRY_IN_SEC;
|
||||
if(t!= null){
|
||||
expiryTime = Integer.parseInt(t);
|
||||
}
|
||||
|
||||
int maxExpiryTime = DEFAULT_MAX_EXPIRY_TIME_IN_MIN;
|
||||
|
||||
t = System.getProperty("MAX_EXPIRY_TIME_IN_MINS");
|
||||
if(t!= null){
|
||||
maxExpiryTime = Integer.parseInt(t);
|
||||
}
|
||||
|
||||
if(maxExpiryTimeInMins != null){
|
||||
maxExpiryTime = maxExpiryTimeInMins;
|
||||
}
|
||||
if(expiryTimeInMins != null){
|
||||
if(expiryTimeInMins > maxExpiryTime) {
|
||||
throw new ServiceException.BadRequestExpception(
|
||||
String.format("expiryTimeInMins %s can't be greated than %s", expiryTimeInMins, maxExpiryTime));
|
||||
}
|
||||
expiryTime = expiryTimeInMins * 60;
|
||||
}
|
||||
|
||||
Authentication authentication = Jenkins.getInstance().getAuthentication();
|
||||
|
||||
if(authentication == null){
|
||||
throw new ServiceException.UnauthorizedException("Unauthorized: No login session found");
|
||||
}
|
||||
String userId = authentication.getName();
|
||||
|
||||
User user = User.get(userId, false, Collections.emptyMap());
|
||||
String email = null;
|
||||
String fullName = null;
|
||||
if(user != null) {
|
||||
fullName = user.getFullName();
|
||||
userId = user.getId();
|
||||
Mailer.UserProperty p = user.getProperty(Mailer.UserProperty.class);
|
||||
if(p!=null)
|
||||
email = p.getAddress();
|
||||
}
|
||||
Plugin plugin = Jenkins.getInstance().getPlugin("blueocean-jwt");
|
||||
String issuer = "blueocean-jwt:"+ ((plugin!=null) ? plugin.getWrapper().getVersion() : "");
|
||||
|
||||
JwtToken jwtToken = new JwtToken();
|
||||
jwtToken.claim.put("jti", UUID.randomUUID().toString().replace("-",""));
|
||||
jwtToken.claim.put("iss", issuer);
|
||||
jwtToken.claim.put("sub", userId);
|
||||
jwtToken.claim.put("name", fullName);
|
||||
long currentTime = System.currentTimeMillis()/1000;
|
||||
jwtToken.claim.put("iat", currentTime);
|
||||
jwtToken.claim.put("exp", currentTime+expiryTime);
|
||||
jwtToken.claim.put("nbf", currentTime - DEFAULT_NOT_BEFORE_IN_SEC);
|
||||
|
||||
//set claim
|
||||
JSONObject context = new JSONObject();
|
||||
JSONObject userObject = new JSONObject();
|
||||
userObject.put("id", userId);
|
||||
userObject.put("fullName", fullName);
|
||||
userObject.put("email", email);
|
||||
context.put("user", userObject);
|
||||
jwtToken.claim.put("context", context);
|
||||
|
||||
return jwtToken;
|
||||
}
|
||||
|
||||
public JwkFactory getJwks(String name) {
|
||||
if(name == null){
|
||||
throw new ServiceException.BadRequestExpception("keyId is required");
|
||||
}
|
||||
|
||||
return new JwkFactory(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getIconFileName() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName() {
|
||||
return "BlueOcean Jwt endpoint";
|
||||
}
|
||||
|
||||
public class JwkFactory extends JwkService {
|
||||
private final String keyId;
|
||||
|
||||
public JwkFactory(String keyId) {
|
||||
this.keyId = keyId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject getJwk() {
|
||||
JwtToken.JwtRsaDigitalSignatureKey key = new JwtToken.JwtRsaDigitalSignatureKey(keyId);
|
||||
try {
|
||||
if(!key.exists()){
|
||||
throw new ServiceException.NotFoundException(String.format("kid %s not found", keyId));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new ServiceException.UnexpectedErrorException("Unexpected error: "+e.getMessage(), e);
|
||||
}
|
||||
RSAPublicKey publicKey = key.getPublicKey();
|
||||
JSONObject jwk = new JSONObject();
|
||||
jwk.put("kty", "RSA");
|
||||
jwk.put("alg","RS256");
|
||||
jwk.put("kid",keyId);
|
||||
jwk.put("use", "sig");
|
||||
jwk.put("key_ops", ImmutableList.of("verify"));
|
||||
jwk.put("n", Base64.encode(publicKey.getModulus().toByteArray()));
|
||||
jwk.put("e", Base64.encode(publicKey.getPublicExponent().toByteArray()));
|
||||
return jwk;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?jelly escape-by-default='true'?>
|
||||
<div>
|
||||
BlueOcean JWT plugin: Enables JWT based BlueOcean API authentication
|
||||
</div>
|
|
@ -0,0 +1,135 @@
|
|||
package io.jenkins.blueocean.auth.jwt.impl;
|
||||
|
||||
import com.gargoylesoftware.htmlunit.Page;
|
||||
import hudson.model.User;
|
||||
import hudson.tasks.Mailer;
|
||||
import net.sf.json.JSONObject;
|
||||
import org.jose4j.jwk.RsaJsonWebKey;
|
||||
import org.jose4j.jws.JsonWebSignature;
|
||||
import org.jose4j.jwt.JwtClaims;
|
||||
import org.jose4j.jwt.consumer.JwtConsumer;
|
||||
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
|
||||
import org.jose4j.jwx.JsonWebStructure;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.jvnet.hudson.test.JenkinsRule;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author Vivek Pandey
|
||||
*/
|
||||
public class JwtImplTest {
|
||||
|
||||
@Rule
|
||||
public JenkinsRule j = new JenkinsRule();
|
||||
|
||||
@Test
|
||||
public void getToken() throws Exception {
|
||||
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||
|
||||
User user = j.jenkins.getUser("alice");
|
||||
user.setFullName("Alice Cooper");
|
||||
user.addProperty(new Mailer.UserProperty("alice@jenkins-ci.org"));
|
||||
|
||||
JenkinsRule.WebClient webClient = j.createWebClient();
|
||||
|
||||
webClient.login("alice");
|
||||
|
||||
Page page = webClient.goTo("jwt-auth/token/", null);
|
||||
String token = page.getWebResponse().getResponseHeaderValue("X-BLUEOCEAN-JWT");
|
||||
|
||||
Assert.assertNotNull(token);
|
||||
|
||||
JsonWebStructure jsonWebStructure = JsonWebStructure.fromCompactSerialization(token);
|
||||
|
||||
Assert.assertTrue(jsonWebStructure instanceof JsonWebSignature);
|
||||
|
||||
JsonWebSignature jsw = (JsonWebSignature) jsonWebStructure;
|
||||
|
||||
System.out.println(token);
|
||||
System.out.println(jsw.toString());
|
||||
|
||||
|
||||
String kid = jsw.getHeader("kid");
|
||||
|
||||
Assert.assertNotNull(kid);
|
||||
|
||||
page = webClient.goTo("jwt-auth/jwks/"+kid+"/", "application/json");
|
||||
|
||||
// for(NameValuePair valuePair: page.getWebResponse().getResponseHeaders()){
|
||||
// System.out.println(valuePair);
|
||||
// }
|
||||
|
||||
JSONObject jsonObject = JSONObject.fromObject(page.getWebResponse().getContentAsString());
|
||||
System.out.println(jsonObject.toString());
|
||||
RsaJsonWebKey rsaJsonWebKey = new RsaJsonWebKey(jsonObject,null);
|
||||
|
||||
JwtConsumer jwtConsumer = new JwtConsumerBuilder()
|
||||
.setRequireExpirationTime() // the JWT must have an expiration time
|
||||
.setAllowedClockSkewInSeconds(30) // allow some leeway in validating time based claims to account for clock skew
|
||||
.setRequireSubject() // the JWT must have a subject claim
|
||||
.setVerificationKey(rsaJsonWebKey.getKey()) // verify the sign with the public key
|
||||
.build(); // create the JwtConsumer instance
|
||||
|
||||
JwtClaims claims = jwtConsumer.processToClaims(token);
|
||||
Assert.assertEquals("alice",claims.getSubject());
|
||||
|
||||
Map<String,Object> claimMap = claims.getClaimsMap();
|
||||
|
||||
Map<String,Object> context = (Map<String, Object>) claimMap.get("context");
|
||||
Map<String,String> userContext = (Map<String, String>) context.get("user");
|
||||
Assert.assertEquals("alice", userContext.get("id"));
|
||||
Assert.assertEquals("Alice Cooper", userContext.get("fullName"));
|
||||
Assert.assertEquals("alice@jenkins-ci.org", userContext.get("email"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anonymousUserToken() throws Exception{
|
||||
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||
|
||||
JenkinsRule.WebClient webClient = j.createWebClient();
|
||||
Page page = webClient.goTo("jwt-auth/token/", null);
|
||||
String token = page.getWebResponse().getResponseHeaderValue("X-BLUEOCEAN-JWT");
|
||||
|
||||
Assert.assertNotNull(token);
|
||||
|
||||
|
||||
JsonWebStructure jsonWebStructure = JsonWebStructure.fromCompactSerialization(token);
|
||||
|
||||
Assert.assertTrue(jsonWebStructure instanceof JsonWebSignature);
|
||||
|
||||
JsonWebSignature jsw = (JsonWebSignature) jsonWebStructure;
|
||||
|
||||
|
||||
String kid = jsw.getHeader("kid");
|
||||
|
||||
Assert.assertNotNull(kid);
|
||||
|
||||
page = webClient.goTo("jwt-auth/jwks/"+kid+"/", "application/json");
|
||||
|
||||
// for(NameValuePair valuePair: page.getWebResponse().getResponseHeaders()){
|
||||
// System.out.println(valuePair);
|
||||
// }
|
||||
|
||||
JSONObject jsonObject = JSONObject.fromObject(page.getWebResponse().getContentAsString());
|
||||
RsaJsonWebKey rsaJsonWebKey = new RsaJsonWebKey(jsonObject,null);
|
||||
|
||||
JwtConsumer jwtConsumer = new JwtConsumerBuilder()
|
||||
.setRequireExpirationTime() // the JWT must have an expiration time
|
||||
.setAllowedClockSkewInSeconds(30) // allow some leeway in validating time based claims to account for clock skew
|
||||
.setRequireSubject() // the JWT must have a subject claim
|
||||
.setVerificationKey(rsaJsonWebKey.getKey()) // verify the sign with the public key
|
||||
.build(); // create the JwtConsumer instance
|
||||
|
||||
JwtClaims claims = jwtConsumer.processToClaims(token);
|
||||
Assert.assertEquals("anonymous",claims.getSubject());
|
||||
|
||||
Map<String,Object> claimMap = claims.getClaimsMap();
|
||||
|
||||
Map<String,Object> context = (Map<String, Object>) claimMap.get("context");
|
||||
Map<String,String> userContext = (Map<String, String>) context.get("user");
|
||||
Assert.assertEquals("anonymous", userContext.get("id"));
|
||||
}
|
||||
}
|
|
@ -34,13 +34,12 @@
|
|||
"react-addons-test-utils": "15.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jenkins-cd/blueocean-core-js": "0.0.1-beta5",
|
||||
"@jenkins-cd/blueocean-core-js": "0.0.4",
|
||||
"@jenkins-cd/design-language": "0.0.70",
|
||||
"@jenkins-cd/js-extensions": "0.0.22",
|
||||
"@jenkins-cd/js-modules": "0.0.6",
|
||||
"@jenkins-cd/sse-gateway": "0.0.7",
|
||||
"immutable": "3.8.1",
|
||||
"isomorphic-fetch": "2.2.1",
|
||||
"keymirror": "0.1.1",
|
||||
"moment": "2.13.0",
|
||||
"moment-duration-format": "1.3.0",
|
||||
|
@ -58,7 +57,6 @@
|
|||
"import": [
|
||||
"@jenkins-cd/sse-gateway",
|
||||
"immutable",
|
||||
"isomorphic-fetch",
|
||||
"keymirror",
|
||||
"react-redux",
|
||||
"react-router",
|
||||
|
|
|
@ -1,27 +0,0 @@
|
|||
//
|
||||
// General "system" config information.
|
||||
//
|
||||
// TODO: This should be in a general sharable component.
|
||||
// Passing it around in the react context is silly.
|
||||
//
|
||||
|
||||
exports.blueoceanAppURL = '/';
|
||||
exports.jenkinsRootURL = '';
|
||||
|
||||
exports.loadConfig = function () {
|
||||
try {
|
||||
const headElement = document.getElementsByTagName('head')[0];
|
||||
|
||||
// Look up where the Blue Ocean app is hosted
|
||||
exports.blueoceanAppURL = headElement.getAttribute('data-appurl');
|
||||
|
||||
if (typeof exports.blueoceanAppURL !== 'string') {
|
||||
exports.blueoceanAppURL = '/';
|
||||
}
|
||||
|
||||
exports.jenkinsRootURL = headElement.getAttribute('data-rooturl');
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('error reading attributes from document; urls will be empty');
|
||||
}
|
||||
};
|
|
@ -1,7 +1,6 @@
|
|||
/**
|
||||
* Created by cmeyers on 8/12/16.
|
||||
*/
|
||||
import fetch from 'isomorphic-fetch';
|
||||
import * as sse from '@jenkins-cd/sse-gateway';
|
||||
|
||||
import { SseBus } from '../model/SseBus';
|
||||
|
@ -21,7 +20,7 @@ class FavoritesSseListener {
|
|||
}
|
||||
|
||||
this.store = store;
|
||||
this.sseBus = new SseBus(sse, fetch);
|
||||
this.sseBus = new SseBus(sse);
|
||||
this.sseBus.subscribeToJob(
|
||||
jobListener,
|
||||
(event) => this._filterJobs(event)
|
||||
|
|
|
@ -1,37 +1,9 @@
|
|||
/**
|
||||
* Created by cmeyers on 7/29/16.
|
||||
*/
|
||||
import defaultFetch from 'isomorphic-fetch';
|
||||
|
||||
import { Fetch, FetchFunctions, UrlConfig } from '@jenkins-cd/blueocean-core-js';
|
||||
import { cleanSlashes } from '../util/UrlUtils';
|
||||
import urlConfig from '../config';
|
||||
urlConfig.loadConfig();
|
||||
|
||||
const defaultFetchOptions = {
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
|
||||
// TODO: migrate all this code down to 'fetch'
|
||||
function checkStatus(response) {
|
||||
if (response.status >= 300 || response.status < 200) {
|
||||
const error = new Error(response.statusText);
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(response) {
|
||||
return response.json()
|
||||
// FIXME: workaround for status=200 w/ empty response body that causes error in Chrome
|
||||
// server should probably return HTTP 204 instead
|
||||
.catch((error) => {
|
||||
if (error.message === 'Unexpected end of JSON input') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function clone(json) {
|
||||
return JSON.parse(JSON.stringify(json));
|
||||
|
@ -42,9 +14,8 @@ function clone(json) {
|
|||
*/
|
||||
export class SseBus {
|
||||
|
||||
constructor(sse, fetch) {
|
||||
constructor(sse) {
|
||||
this.sse = sse;
|
||||
this.fetch = fetch || defaultFetch;
|
||||
this.jobListenerSse = null;
|
||||
this.jobListenerExternal = null;
|
||||
this.jobFilter = null;
|
||||
|
@ -141,12 +112,10 @@ export class SseBus {
|
|||
}
|
||||
|
||||
_updateJob(event) {
|
||||
const baseUrl = urlConfig.jenkinsRootURL;
|
||||
const baseUrl = UrlConfig.getJenkinsRootURL();
|
||||
const url = cleanSlashes(`${baseUrl}/${event.blueocean_job_rest_url}/runs/${event.jenkins_object_id}`);
|
||||
|
||||
this.fetch(url, defaultFetchOptions)
|
||||
.then(checkStatus)
|
||||
.then(parseJSON)
|
||||
Fetch.fetchJSON(url)
|
||||
.then((data) => {
|
||||
const updatedRun = clone(data);
|
||||
|
||||
|
@ -161,7 +130,7 @@ export class SseBus {
|
|||
if (this.jobListenerExternal) {
|
||||
this.jobListenerExternal(updatedRun);
|
||||
}
|
||||
});
|
||||
}).catch(FetchFunctions.consoleError);
|
||||
}
|
||||
|
||||
_updateMultiBranchPipelineBranches() {
|
||||
|
|
|
@ -1,37 +1,10 @@
|
|||
/**
|
||||
* Created by cmeyers on 7/6/16.
|
||||
*/
|
||||
import fetch from 'isomorphic-fetch';
|
||||
|
||||
import { ACTION_TYPES } from './FavoritesStore';
|
||||
import { UrlConfig, Fetch } from '@jenkins-cd/blueocean-core-js';
|
||||
import { cleanSlashes } from '../util/UrlUtils';
|
||||
import urlConfig from '../config';
|
||||
urlConfig.loadConfig();
|
||||
|
||||
const defaultFetchOptions = {
|
||||
credentials: 'same-origin',
|
||||
};
|
||||
|
||||
function checkStatus(response) {
|
||||
if (response.status >= 300 || response.status < 200) {
|
||||
const error = new Error(response.statusText);
|
||||
error.response = response;
|
||||
throw error;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
function parseJSON(response) {
|
||||
return response.json()
|
||||
// FIXME: workaround for status=200 w/ empty response body that causes error in Chrome
|
||||
// server should probably return HTTP 204 instead
|
||||
.catch((error) => {
|
||||
if (error.message === 'Unexpected end of JSON input') {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
const fetchFlags = {
|
||||
[ACTION_TYPES.SET_USER]: false,
|
||||
|
@ -41,9 +14,8 @@ const fetchFlags = {
|
|||
export const actions = {
|
||||
fetchUser() {
|
||||
return (dispatch) => {
|
||||
const baseUrl = urlConfig.blueoceanAppURL;
|
||||
const baseUrl = UrlConfig.getBlueOceanAppURL();
|
||||
const url = cleanSlashes(`${baseUrl}/rest/organizations/jenkins/user/`);
|
||||
const fetchOptions = { ...defaultFetchOptions };
|
||||
|
||||
if (fetchFlags[ACTION_TYPES.SET_USER]) {
|
||||
return null;
|
||||
|
@ -52,7 +24,7 @@ export const actions = {
|
|||
fetchFlags[ACTION_TYPES.SET_USER] = true;
|
||||
|
||||
return dispatch(actions.generateData(
|
||||
{ url, fetchOptions },
|
||||
{ url },
|
||||
ACTION_TYPES.SET_USER
|
||||
));
|
||||
};
|
||||
|
@ -60,10 +32,9 @@ export const actions = {
|
|||
|
||||
fetchFavorites(user) {
|
||||
return (dispatch) => {
|
||||
const baseUrl = urlConfig.blueoceanAppURL;
|
||||
const baseUrl = UrlConfig.getBlueOceanAppURL();
|
||||
const username = user.id;
|
||||
const url = cleanSlashes(`${baseUrl}/rest/users/${username}/favorites/`);
|
||||
const fetchOptions = { ...defaultFetchOptions };
|
||||
|
||||
if (fetchFlags[ACTION_TYPES.SET_FAVORITES]) {
|
||||
return null;
|
||||
|
@ -72,7 +43,7 @@ export const actions = {
|
|||
fetchFlags[ACTION_TYPES.SET_FAVORITES] = true;
|
||||
|
||||
return dispatch(actions.generateData(
|
||||
{ url, fetchOptions },
|
||||
{ url },
|
||||
ACTION_TYPES.SET_FAVORITES
|
||||
));
|
||||
};
|
||||
|
@ -80,16 +51,14 @@ export const actions = {
|
|||
|
||||
toggleFavorite(addFavorite, branch, favoriteToRemove) {
|
||||
return (dispatch) => {
|
||||
const baseUrl = urlConfig.jenkinsRootURL;
|
||||
|
||||
const url = cleanSlashes(
|
||||
addFavorite ?
|
||||
const baseUrl = UrlConfig.getJenkinsRootURL();
|
||||
const url = cleanSlashes(addFavorite ?
|
||||
`${baseUrl}${branch._links.self.href}/favorite` :
|
||||
`${baseUrl}${favoriteToRemove._links.self.href}`
|
||||
);
|
||||
|
||||
|
||||
|
||||
const fetchOptions = {
|
||||
...defaultFetchOptions,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -109,12 +78,11 @@ export const actions = {
|
|||
|
||||
runPipeline(pipeline) {
|
||||
return () => {
|
||||
const baseUrl = urlConfig.jenkinsRootURL;
|
||||
const baseUrl = UrlConfig.getJenkinsRootURL();
|
||||
const pipelineUrl = pipeline._links.self.href;
|
||||
const runPipelineUrl = cleanSlashes(`${baseUrl}/${pipelineUrl}/runs/`);
|
||||
|
||||
const fetchOptions = {
|
||||
...defaultFetchOptions,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -122,18 +90,17 @@ export const actions = {
|
|||
};
|
||||
|
||||
// once job is queued, SSE will fire and trigger "updateRun" so no need to dispatch an action here
|
||||
fetch(runPipelineUrl, fetchOptions);
|
||||
Fetch.fetch(runPipelineUrl, { fetchOptions });
|
||||
};
|
||||
},
|
||||
|
||||
replayPipeline(pipeline) {
|
||||
return () => {
|
||||
const baseUrl = urlConfig.jenkinsRootURL;
|
||||
const baseUrl = UrlConfig.getJenkinsRootURL();
|
||||
const pipelineUrl = pipeline.latestRun._links.self.href;
|
||||
const runPipelineUrl = cleanSlashes(`${baseUrl}/${pipelineUrl}/replay/`);
|
||||
|
||||
const fetchOptions = {
|
||||
...defaultFetchOptions,
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
@ -141,7 +108,7 @@ export const actions = {
|
|||
};
|
||||
|
||||
// once job is queued, SSE will fire and trigger "updateRun" so no need to dispatch an action here
|
||||
fetch(runPipelineUrl, fetchOptions);
|
||||
Fetch.fetch(runPipelineUrl, { fetchOptions });
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -156,9 +123,7 @@ export const actions = {
|
|||
|
||||
generateData(request, actionType, optional) {
|
||||
const { url, fetchOptions } = request;
|
||||
return (dispatch) => fetch(url, fetchOptions)
|
||||
.then(checkStatus)
|
||||
.then(parseJSON)
|
||||
return (dispatch) => Fetch.fetchJSON(url, { fetchOptions })
|
||||
.then((json) => {
|
||||
fetchFlags[actionType] = false;
|
||||
return dispatch({
|
||||
|
|
|
@ -508,9 +508,10 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
WorkflowJob p = scheduleAndFindBranchProject(mp, "master");
|
||||
j.waitUntilNoActivity();
|
||||
|
||||
String token = getJwtToken(j.jenkins, "alice", "alice");
|
||||
Map m = new RequestBuilder(baseUrl)
|
||||
.put("/organizations/jenkins/pipelines/p/favorite")
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", true))
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -522,7 +523,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
List l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(1,l.size());
|
||||
|
@ -539,7 +540,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
m = new RequestBuilder(baseUrl)
|
||||
.put(getUrlFromHref(ref))
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", false))
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -551,7 +552,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(0,l.size());
|
||||
|
@ -559,7 +560,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("bob","bob")
|
||||
.jwtToken(getJwtToken(j.jenkins,"bob","bob"))
|
||||
.status(403)
|
||||
.build(String.class);
|
||||
}
|
||||
|
@ -582,9 +583,10 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
WorkflowJob p1 = scheduleAndFindBranchProject(mp, "feature2");
|
||||
|
||||
String token = getJwtToken(j.jenkins,"alice","alice");
|
||||
Map map = new RequestBuilder(baseUrl)
|
||||
.put("/organizations/jenkins/pipelines/p/branches/feature2/favorite")
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", true))
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -595,7 +597,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
List l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(1, l.size());
|
||||
|
@ -612,7 +614,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
map = new RequestBuilder(baseUrl)
|
||||
.put(getUrlFromHref(getHrefFromLinks((Map)l.get(0), "self")))
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", false))
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -623,7 +625,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(0, l.size());
|
||||
|
@ -631,7 +633,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("bob","bob")
|
||||
.jwtToken(getJwtToken(j.jenkins,"bob","bob"))
|
||||
.status(403)
|
||||
.build(String.class);
|
||||
|
||||
|
@ -661,9 +663,11 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
fup.toggleFavorite(mp.getFullName());
|
||||
user.save();
|
||||
|
||||
String token = getJwtToken(j.jenkins,"alice", "alice");
|
||||
|
||||
List l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(1, l.size());
|
||||
|
@ -682,7 +686,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
Map m = new RequestBuilder(baseUrl)
|
||||
.put(getUrlFromHref(getUrlFromHref(href)))
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", false))
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -693,7 +697,7 @@ public class MultiBranchTest extends PipelineBaseTest {
|
|||
|
||||
l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(0,l.size());
|
||||
|
|
|
@ -6,6 +6,7 @@ import com.mashape.unirest.http.HttpResponse;
|
|||
import com.mashape.unirest.http.ObjectMapper;
|
||||
import com.mashape.unirest.http.Unirest;
|
||||
import com.mashape.unirest.http.exceptions.UnirestException;
|
||||
import com.mashape.unirest.request.GetRequest;
|
||||
import com.mashape.unirest.request.HttpRequest;
|
||||
import com.mashape.unirest.request.HttpRequestWithBody;
|
||||
import hudson.Util;
|
||||
|
@ -13,6 +14,7 @@ import hudson.model.Job;
|
|||
import hudson.model.Run;
|
||||
import io.jenkins.blueocean.commons.JsonConverter;
|
||||
import jenkins.branch.MultiBranchProject;
|
||||
import jenkins.model.Jenkins;
|
||||
import org.jenkinsci.plugins.workflow.actions.ThreadNameAction;
|
||||
import org.jenkinsci.plugins.workflow.graph.FlowNode;
|
||||
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
|
||||
|
@ -34,6 +36,8 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.logging.LogManager;
|
||||
|
||||
import static io.jenkins.blueocean.auth.jwt.JwtToken.X_BLUEOCEAN_JWT;
|
||||
|
||||
/**
|
||||
* @author Vivek Pandey
|
||||
*/
|
||||
|
@ -45,6 +49,8 @@ public abstract class PipelineBaseTest{
|
|||
|
||||
protected String baseUrl;
|
||||
|
||||
protected String jwtToken;
|
||||
|
||||
protected String getContextPath(){
|
||||
return "blue/rest";
|
||||
}
|
||||
|
@ -56,6 +62,7 @@ public abstract class PipelineBaseTest{
|
|||
LogManager.getLogManager().readConfiguration(is);
|
||||
}
|
||||
this.baseUrl = j.jenkins.getRootUrl() + getContextPath();
|
||||
this.jwtToken = getJwtToken(j.jenkins);
|
||||
Unirest.setObjectMapper(new ObjectMapper() {
|
||||
public <T> T readValue(String value, Class<T> valueType) {
|
||||
try {
|
||||
|
@ -109,12 +116,14 @@ public abstract class PipelineBaseTest{
|
|||
if(HttpResponse.class.isAssignableFrom(type)){
|
||||
HttpResponse<String> response = Unirest.get(getBaseUrl(path)).header("Accept", accept)
|
||||
.header("Accept-Encoding","")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.asString();
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return (T) response;
|
||||
}
|
||||
|
||||
HttpResponse<T> response = Unirest.get(getBaseUrl(path)).header("Accept", accept).asObject(type);
|
||||
HttpResponse<T> response = Unirest.get(getBaseUrl(path)).header("Accept", accept)
|
||||
.header("Authorization", "Bearer "+jwtToken).asObject(type);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
} catch (UnirestException e) {
|
||||
|
@ -145,6 +154,7 @@ public abstract class PipelineBaseTest{
|
|||
try {
|
||||
HttpResponse<Map> response = Unirest.post(getBaseUrl(path))
|
||||
.header("Content-Type","application/json")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(Map.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -159,6 +169,7 @@ public abstract class PipelineBaseTest{
|
|||
HttpResponse<String> response = Unirest.post(getBaseUrl(path))
|
||||
.header("Content-Type",contentType)
|
||||
.header("Accept-Encoding","")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(String.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -179,6 +190,7 @@ public abstract class PipelineBaseTest{
|
|||
HttpResponse<Map> response = Unirest.put(getBaseUrl(path))
|
||||
.header("Content-Type","application/json")
|
||||
.header("Accept","application/json")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
//Unirest by default sets accept-encoding to gzip but stapler is sending malformed gzip value if
|
||||
// the response length is small (in this case its 20 chars).
|
||||
// Needs investigation in stapler to see whats going on there.
|
||||
|
@ -198,6 +210,7 @@ public abstract class PipelineBaseTest{
|
|||
try {
|
||||
HttpResponse<String> response = Unirest.put(getBaseUrl(path))
|
||||
.header("Content-Type",contentType)
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(String.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -215,6 +228,7 @@ public abstract class PipelineBaseTest{
|
|||
try {
|
||||
HttpResponse<Map> response = Unirest.patch(getBaseUrl(path))
|
||||
.header("Content-Type","application/json")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(Map.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -229,6 +243,7 @@ public abstract class PipelineBaseTest{
|
|||
try {
|
||||
HttpResponse<String> response = Unirest.patch(getBaseUrl(path))
|
||||
.header("Content-Type",contentType)
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(String.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -353,7 +368,7 @@ public abstract class PipelineBaseTest{
|
|||
public RequestBuilder request() {
|
||||
return new RequestBuilder(baseUrl);
|
||||
}
|
||||
public static class RequestBuilder {
|
||||
public class RequestBuilder {
|
||||
private String url;
|
||||
private String username;
|
||||
private String method;
|
||||
|
@ -362,6 +377,7 @@ public abstract class PipelineBaseTest{
|
|||
private String contentType = "application/json";
|
||||
private String baseUrl;
|
||||
private int expectedStatus = 200;
|
||||
private String token;
|
||||
|
||||
|
||||
private String getBaseUrl(String path){
|
||||
|
@ -394,6 +410,10 @@ public abstract class PipelineBaseTest{
|
|||
return this;
|
||||
}
|
||||
|
||||
public RequestBuilder jwtToken(String token){
|
||||
this.token = token;
|
||||
return this;
|
||||
}
|
||||
|
||||
public RequestBuilder data(Map data) {
|
||||
this.data = data;
|
||||
|
@ -454,6 +474,11 @@ public abstract class PipelineBaseTest{
|
|||
|
||||
}
|
||||
request.header("Accept-Encoding","");
|
||||
if(token == null) {
|
||||
request.header("Authorization", "Bearer " + PipelineBaseTest.this.jwtToken);
|
||||
}else{
|
||||
request.header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
request.header("Content-Type", contentType);
|
||||
if(!Strings.isNullOrEmpty(username) && !Strings.isNullOrEmpty(password)){
|
||||
|
@ -471,4 +496,30 @@ public abstract class PipelineBaseTest{
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String getJwtToken(Jenkins jenkins) throws UnirestException {
|
||||
HttpResponse<String> response = Unirest.get(jenkins.getRootUrl()+"jwt-auth/token/").header("Accept", "*/*")
|
||||
.header("Accept-Encoding","").asString();
|
||||
|
||||
String token = response.getHeaders().getFirst(X_BLUEOCEAN_JWT);
|
||||
Assert.assertNotNull(token);
|
||||
//we do not validate it for test optimization and for the fact that there are separate
|
||||
// tests that test token generation and validation
|
||||
return token;
|
||||
}
|
||||
|
||||
public static String getJwtToken(Jenkins jenkins, String username, String password) throws UnirestException {
|
||||
GetRequest request = Unirest.get(jenkins.getRootUrl()+"jwt-auth/token/").header("Accept", "*/*")
|
||||
.header("Accept-Encoding","");
|
||||
if(username!= null && password!= null){
|
||||
request.basicAuth(username,password);
|
||||
}
|
||||
|
||||
HttpResponse<String> response = request.asString();
|
||||
String token = response.getHeaders().getFirst(X_BLUEOCEAN_JWT);
|
||||
Assert.assertNotNull(token);
|
||||
//we do not validate it for test optimization and for the fact that there are separate
|
||||
// tests that test token generation and validation
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,10 @@
|
|||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>blueocean-commons</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>blueocean-jwt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>blueocean-rest-impl</artifactId>
|
||||
|
@ -107,9 +111,10 @@
|
|||
//
|
||||
linkHPI('blueocean-web');
|
||||
linkHPI('blueocean-dashboard');
|
||||
linkHPI('blueocean-personalization')
|
||||
linkHPI('blueocean-personalization');
|
||||
linkHPI('blueocean-rest');
|
||||
linkHPI('blueocean-commons');
|
||||
linkHPI('blueocean-jwt');
|
||||
linkHPI('blueocean-rest-impl');
|
||||
linkHPI('blueocean-pipeline-api-impl')
|
||||
linkHPI('blueocean-config')
|
||||
|
|
|
@ -28,6 +28,10 @@
|
|||
<artifactId>scm-api</artifactId>
|
||||
<version>1.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>blueocean-jwt</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Test plugins -->
|
||||
<dependency>
|
||||
|
|
|
@ -4,17 +4,25 @@ import com.google.inject.Binder;
|
|||
import com.google.inject.Inject;
|
||||
import com.google.inject.Module;
|
||||
import hudson.Extension;
|
||||
import hudson.model.RootAction;
|
||||
import hudson.model.UnprotectedRootAction;
|
||||
import io.jenkins.blueocean.BlueOceanUI;
|
||||
import org.acegisecurity.Authentication;
|
||||
import org.acegisecurity.context.SecurityContext;
|
||||
import org.acegisecurity.context.SecurityContextHolder;
|
||||
import org.acegisecurity.context.SecurityContextImpl;
|
||||
import org.kohsuke.stapler.Stapler;
|
||||
import org.kohsuke.stapler.StaplerProxy;
|
||||
import org.kohsuke.stapler.StaplerRequest;
|
||||
|
||||
/**
|
||||
* @author Kohsuke Kawaguchi
|
||||
*/
|
||||
@Extension
|
||||
public class BlueOceanRootAction implements RootAction, StaplerProxy {
|
||||
public class BlueOceanRootAction implements UnprotectedRootAction, StaplerProxy {
|
||||
private static final String URL_BASE="blue";
|
||||
|
||||
private final boolean disableJWT = Boolean.getBoolean("DISABLE_BLUEOCEAN_JWT_AUTHENTICATION");
|
||||
|
||||
@Inject
|
||||
private BlueOceanUI app;
|
||||
|
||||
|
@ -38,6 +46,19 @@ public class BlueOceanRootAction implements RootAction, StaplerProxy {
|
|||
|
||||
@Override
|
||||
public Object getTarget() {
|
||||
|
||||
StaplerRequest request = Stapler.getCurrentRequest();
|
||||
|
||||
if(!disableJWT && request.getOriginalRestOfPath().startsWith("/rest/")) {
|
||||
Authentication tokenAuthentication = JwtAuthenticationToken.create(request);
|
||||
|
||||
//create a new context and set it to holder to not clobber existing context
|
||||
SecurityContext securityContext = new SecurityContextImpl();
|
||||
securityContext.setAuthentication(tokenAuthentication);
|
||||
SecurityContextHolder.setContext(securityContext);
|
||||
|
||||
//TODO: implement this as filter, see PluginServletFilter to clear the context
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
package io.jenkins.blueocean.service.embedded;
|
||||
|
||||
import hudson.model.User;
|
||||
import io.jenkins.blueocean.auth.jwt.JwtToken;
|
||||
import io.jenkins.blueocean.commons.ServiceException;
|
||||
import jenkins.model.Jenkins;
|
||||
import org.acegisecurity.Authentication;
|
||||
import org.acegisecurity.GrantedAuthority;
|
||||
import org.acegisecurity.providers.AbstractAuthenticationToken;
|
||||
import org.acegisecurity.userdetails.UserDetails;
|
||||
import org.jose4j.jwt.JwtClaims;
|
||||
import org.jose4j.jwt.MalformedClaimException;
|
||||
import org.jose4j.jwt.NumericDate;
|
||||
import org.jose4j.jwt.consumer.InvalidJwtException;
|
||||
import org.jose4j.jwt.consumer.JwtConsumer;
|
||||
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
|
||||
import org.jose4j.jwt.consumer.JwtContext;
|
||||
import org.jose4j.jwx.JsonWebStructure;
|
||||
import org.jose4j.lang.JoseException;
|
||||
import org.kohsuke.stapler.StaplerRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.jose4j.jws.AlgorithmIdentifiers.RSA_USING_SHA256;
|
||||
|
||||
/**
|
||||
* @author Kohsuke Kawaguchi
|
||||
*/
|
||||
public final class JwtAuthenticationToken extends AbstractAuthenticationToken{
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationToken.class);
|
||||
|
||||
private final String name;
|
||||
private final GrantedAuthority[] grantedAuthorities;
|
||||
|
||||
public static Authentication create(StaplerRequest request){
|
||||
JwtClaims claims = validate(request);
|
||||
String subject = null;
|
||||
try {
|
||||
subject = claims.getSubject();
|
||||
|
||||
if(subject.equals("anonymous")) { //if anonymous, we don't look in user db
|
||||
return Jenkins.getInstance().ANONYMOUS;
|
||||
}else{
|
||||
return new JwtAuthenticationToken(subject);
|
||||
}
|
||||
} catch (MalformedClaimException e) {
|
||||
logger.error(String.format("Error reading sub header for token %s",claims.getRawJson()),e);
|
||||
throw new ServiceException.UnauthorizedException("Invalid JWT token: malformed claim");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public JwtAuthenticationToken(String subject) {
|
||||
User user = User.get(subject, false, Collections.emptyMap());
|
||||
if (user == null) {
|
||||
throw new ServiceException.UnauthorizedException("Invalid JWT token: subject " + subject + " not found");
|
||||
}
|
||||
//TODO: UserDetails call is expensive, encode it in token and create UserDetails from it
|
||||
UserDetails d = Jenkins.getInstance().getSecurityRealm().loadUserByUsername(user.getId());
|
||||
this.grantedAuthorities = d.getAuthorities();
|
||||
this.name = subject;
|
||||
super.setAuthenticated(true);
|
||||
}
|
||||
|
||||
private static JwtClaims validate(StaplerRequest request) {
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
if(authHeader == null || !authHeader.startsWith("Bearer ")){
|
||||
throw new ServiceException.UnauthorizedException("JWT token not found");
|
||||
}
|
||||
String token = authHeader.substring("Bearer ".length());
|
||||
try {
|
||||
JsonWebStructure jws = JsonWebStructure.fromCompactSerialization(token);
|
||||
String alg = jws.getAlgorithmHeaderValue();
|
||||
if(alg == null || !alg.equals(RSA_USING_SHA256)){
|
||||
logger.error(String.format("Invalid JWT token: unsupported algorithm in header, found %s, expected %s", alg, RSA_USING_SHA256));
|
||||
throw new ServiceException.UnauthorizedException("Invalid JWT token");
|
||||
}
|
||||
|
||||
String kid = jws.getKeyIdHeaderValue();
|
||||
|
||||
if(kid == null){
|
||||
logger.error("Invalid JWT token: missing kid");
|
||||
throw new ServiceException.UnauthorizedException("Invalid JWT token");
|
||||
}
|
||||
|
||||
JwtToken.JwtRsaDigitalSignatureKey key = new JwtToken.JwtRsaDigitalSignatureKey(kid);
|
||||
try {
|
||||
if(!key.exists()){
|
||||
throw new ServiceException.NotFoundException(String.format("kid %s not found", kid));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.error(String.format("Error reading RSA key for id %s: %s",kid,e.getMessage()),e);
|
||||
throw new ServiceException.UnexpectedErrorException("Unexpected error: "+e.getMessage(), e);
|
||||
}
|
||||
|
||||
JwtConsumer jwtConsumer = new JwtConsumerBuilder()
|
||||
.setRequireExpirationTime() // the JWT must have an expiration time
|
||||
.setRequireJwtId()
|
||||
.setAllowedClockSkewInSeconds(30) // allow some leeway in validating time based claims to account for clock skew
|
||||
.setRequireSubject() // the JWT must have a subject claim
|
||||
.setVerificationKey(key.getPublicKey()) // verify the sign with the public key
|
||||
.build(); // create the JwtConsumer instance
|
||||
|
||||
try {
|
||||
JwtContext context = jwtConsumer.process(token);
|
||||
JwtClaims claims = context.getJwtClaims();
|
||||
|
||||
//check if token expired
|
||||
NumericDate expirationTime = claims.getExpirationTime();
|
||||
if (expirationTime.isBefore(NumericDate.now())){
|
||||
throw new ServiceException.UnauthorizedException("Invalid JWT token: expired");
|
||||
}
|
||||
return claims;
|
||||
} catch (InvalidJwtException e) {
|
||||
logger.error("Invalid JWT token: "+e.getMessage(), e);
|
||||
throw new ServiceException.UnauthorizedException("Invalid JWT token");
|
||||
} catch (MalformedClaimException e) {
|
||||
logger.error(String.format("Error reading sub header for token %s",jws.getPayload()),e);
|
||||
throw new ServiceException.UnauthorizedException("Invalid JWT token: malformed claim");
|
||||
}
|
||||
} catch (JoseException e) {
|
||||
logger.error("Error parsing JWT token: "+e.getMessage(), e);
|
||||
throw new ServiceException.UnauthorizedException("Invalid JWT Token: "+ e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getCredentials() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getPrincipal() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GrantedAuthority[] getAuthorities() {
|
||||
return grantedAuthorities;
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ package io.jenkins.blueocean.service.embedded.rest;
|
|||
|
||||
import hudson.model.Cause;
|
||||
import hudson.model.CauseAction;
|
||||
import hudson.model.Item;
|
||||
import hudson.model.Job;
|
||||
import hudson.model.Queue;
|
||||
import hudson.model.queue.ScheduleResult;
|
||||
|
@ -77,6 +78,7 @@ public class RunContainerImpl extends BlueRunContainer {
|
|||
*/
|
||||
@Override
|
||||
public BlueQueueItem create() {
|
||||
job.checkPermission(Item.BUILD);
|
||||
if (job instanceof Queue.Task) {
|
||||
ScheduleResult scheduleResult = Jenkins.getInstance()
|
||||
.getQueue()
|
||||
|
|
|
@ -11,6 +11,8 @@ import io.jenkins.blueocean.rest.model.BlueFavoriteContainer;
|
|||
import io.jenkins.blueocean.rest.model.BlueUser;
|
||||
import jenkins.model.Jenkins;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
/**
|
||||
* {@link BlueUser} implementation backed by in-memory {@link User}
|
||||
*
|
||||
|
@ -43,7 +45,16 @@ public class UserImpl extends BlueUser {
|
|||
|
||||
@Override
|
||||
public String getEmail() {
|
||||
if (!user.hasPermission(Jenkins.ADMINISTER)) return null;
|
||||
String name = Jenkins.getAuthentication().getName();
|
||||
if(name.equals("anonymous") || user.getId().equals("anonymous")){
|
||||
return null;
|
||||
}else{
|
||||
User user = User.get(name, false, Collections.EMPTY_MAP);
|
||||
if(user == null){
|
||||
return null;
|
||||
}
|
||||
if (!user.hasPermission(Jenkins.ADMINISTER)) return null;
|
||||
}
|
||||
|
||||
Mailer.UserProperty p = user.getProperty(Mailer.UserProperty.class);
|
||||
return p != null ? p.getAddress() : null;
|
||||
|
|
|
@ -6,11 +6,14 @@ import com.mashape.unirest.http.HttpResponse;
|
|||
import com.mashape.unirest.http.ObjectMapper;
|
||||
import com.mashape.unirest.http.Unirest;
|
||||
import com.mashape.unirest.http.exceptions.UnirestException;
|
||||
import com.mashape.unirest.request.GetRequest;
|
||||
import com.mashape.unirest.request.HttpRequest;
|
||||
import com.mashape.unirest.request.HttpRequestWithBody;
|
||||
import hudson.model.Job;
|
||||
import hudson.model.Run;
|
||||
import io.jenkins.blueocean.commons.JsonConverter;
|
||||
import jenkins.model.Jenkins;
|
||||
import net.sf.json.JSONObject;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
|
@ -26,6 +29,8 @@ import java.util.Date;
|
|||
import java.util.Map;
|
||||
import java.util.logging.LogManager;
|
||||
|
||||
import static io.jenkins.blueocean.auth.jwt.JwtToken.X_BLUEOCEAN_JWT;
|
||||
|
||||
/**
|
||||
* @author Vivek Pandey
|
||||
*/
|
||||
|
@ -41,6 +46,8 @@ public abstract class BaseTest {
|
|||
return "blue/rest";
|
||||
}
|
||||
|
||||
protected String jwtToken;
|
||||
|
||||
@Before
|
||||
public void setup() throws Exception {
|
||||
if(System.getProperty("DISABLE_HTTP_HEADER_TRACE") == null) {
|
||||
|
@ -48,6 +55,7 @@ public abstract class BaseTest {
|
|||
LogManager.getLogManager().readConfiguration(is);
|
||||
}
|
||||
this.baseUrl = j.jenkins.getRootUrl() + getContextPath();
|
||||
this.jwtToken = getJwtToken(j.jenkins);
|
||||
Unirest.setObjectMapper(new ObjectMapper() {
|
||||
public <T> T readValue(String value, Class<T> valueType) {
|
||||
try {
|
||||
|
@ -101,12 +109,13 @@ public abstract class BaseTest {
|
|||
if(HttpResponse.class.isAssignableFrom(type)){
|
||||
HttpResponse<String> response = Unirest.get(getBaseUrl(path)).header("Accept", accept)
|
||||
.header("Accept-Encoding","")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.asString();
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return (T) response;
|
||||
}
|
||||
|
||||
HttpResponse<T> response = Unirest.get(getBaseUrl(path)).header("Accept", accept).asObject(type);
|
||||
HttpResponse<T> response = Unirest.get(getBaseUrl(path)).header("Accept", accept).header("Authorization", "Bearer "+jwtToken).asObject(type);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
} catch (UnirestException e) {
|
||||
|
@ -118,7 +127,8 @@ public abstract class BaseTest {
|
|||
protected Map delete(String path){
|
||||
assert path.startsWith("/");
|
||||
try {
|
||||
HttpResponse<Map> response = Unirest.delete(getBaseUrl(path)).asObject(Map.class);
|
||||
HttpResponse<Map> response = Unirest.delete(getBaseUrl(path))
|
||||
.header("Authorization", "Bearer "+jwtToken).asObject(Map.class);
|
||||
Assert.assertEquals(200, response.getStatus());
|
||||
return response.getBody();
|
||||
} catch (UnirestException e) {
|
||||
|
@ -137,6 +147,7 @@ public abstract class BaseTest {
|
|||
try {
|
||||
HttpResponse<Map> response = Unirest.post(getBaseUrl(path))
|
||||
.header("Content-Type","application/json")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(Map.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -151,6 +162,7 @@ public abstract class BaseTest {
|
|||
HttpResponse<String> response = Unirest.post(getBaseUrl(path))
|
||||
.header("Content-Type",contentType)
|
||||
.header("Accept-Encoding","")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(String.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -171,6 +183,7 @@ public abstract class BaseTest {
|
|||
HttpResponse<Map> response = Unirest.put(getBaseUrl(path))
|
||||
.header("Content-Type","application/json")
|
||||
.header("Accept","application/json")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
//Unirest by default sets accept-encoding to gzip but stapler is sending malformed gzip value if
|
||||
// the response length is small (in this case its 20 chars).
|
||||
// Needs investigation in stapler to see whats going on there.
|
||||
|
@ -190,6 +203,7 @@ public abstract class BaseTest {
|
|||
try {
|
||||
HttpResponse<String> response = Unirest.put(getBaseUrl(path))
|
||||
.header("Content-Type",contentType)
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(String.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -207,6 +221,7 @@ public abstract class BaseTest {
|
|||
try {
|
||||
HttpResponse<Map> response = Unirest.patch(getBaseUrl(path))
|
||||
.header("Content-Type","application/json")
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(Map.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -221,6 +236,7 @@ public abstract class BaseTest {
|
|||
try {
|
||||
HttpResponse<String> response = Unirest.patch(getBaseUrl(path))
|
||||
.header("Content-Type",contentType)
|
||||
.header("Authorization", "Bearer "+jwtToken)
|
||||
.body(body).asObject(String.class);
|
||||
Assert.assertEquals(expectedStatus, response.getStatus());
|
||||
return response.getBody();
|
||||
|
@ -300,7 +316,9 @@ public abstract class BaseTest {
|
|||
public RequestBuilder request() {
|
||||
return new RequestBuilder(baseUrl);
|
||||
}
|
||||
public static class RequestBuilder {
|
||||
|
||||
|
||||
public class RequestBuilder {
|
||||
private String url;
|
||||
private String username;
|
||||
private String method;
|
||||
|
@ -310,6 +328,7 @@ public abstract class BaseTest {
|
|||
private String baseUrl;
|
||||
private int expectedStatus = 200;
|
||||
|
||||
private String token;
|
||||
|
||||
private String getBaseUrl(String path){
|
||||
return baseUrl + path;
|
||||
|
@ -341,6 +360,11 @@ public abstract class BaseTest {
|
|||
return this;
|
||||
}
|
||||
|
||||
public RequestBuilder jwtToken(String token){
|
||||
this.token = token;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public RequestBuilder data(Map data) {
|
||||
this.data = data;
|
||||
|
@ -358,6 +382,11 @@ public abstract class BaseTest {
|
|||
return this;
|
||||
}
|
||||
|
||||
public RequestBuilder patch(String url) {
|
||||
this.url = url;
|
||||
this.method = "PATCH";
|
||||
return this;
|
||||
}
|
||||
|
||||
public RequestBuilder get(String url) {
|
||||
this.url = url;
|
||||
|
@ -387,6 +416,9 @@ public abstract class BaseTest {
|
|||
case "PUT":
|
||||
request = Unirest.put(getBaseUrl(url));
|
||||
break;
|
||||
case "PATCH":
|
||||
request = Unirest.patch(getBaseUrl(url));
|
||||
break;
|
||||
case "POST":
|
||||
request = Unirest.post(getBaseUrl(url));
|
||||
break;
|
||||
|
@ -407,6 +439,12 @@ public abstract class BaseTest {
|
|||
request.basicAuth(username, password);
|
||||
}
|
||||
|
||||
if(token == null) {
|
||||
request.header("Authorization", "Bearer " + BaseTest.this.jwtToken);
|
||||
}else{
|
||||
request.header("Authorization", "Bearer " + token);
|
||||
}
|
||||
|
||||
if(request instanceof HttpRequestWithBody && data != null) {
|
||||
((HttpRequestWithBody)request).body(data);
|
||||
}
|
||||
|
@ -418,4 +456,39 @@ public abstract class BaseTest {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String getJwtToken(Jenkins jenkins) throws UnirestException {
|
||||
HttpResponse<String> response = Unirest.get(jenkins.getRootUrl()+"jwt-auth/token/").header("Accept", "*/*")
|
||||
.header("Accept-Encoding","").asString();
|
||||
|
||||
String token = response.getHeaders().getFirst(X_BLUEOCEAN_JWT);
|
||||
Assert.assertNotNull(token);
|
||||
//we do not validate it for test optimization and for the fact that there are separate
|
||||
// tests that test token generation and validation
|
||||
return token;
|
||||
}
|
||||
|
||||
public static String getJwtToken(Jenkins jenkins, String username, String password) throws UnirestException {
|
||||
GetRequest request = Unirest.get(jenkins.getRootUrl()+"jwt-auth/token/").header("Accept", "*/*")
|
||||
.header("Accept-Encoding","");
|
||||
if(username!= null && password!= null){
|
||||
request.basicAuth(username,password);
|
||||
}
|
||||
|
||||
HttpResponse<String> response = request.asString();
|
||||
String token = response.getHeaders().getFirst(X_BLUEOCEAN_JWT);
|
||||
Assert.assertNotNull(token);
|
||||
//we do not validate it for test optimization and for the fact that there are separate
|
||||
// tests that test token generation and validation
|
||||
int i = token.indexOf('.');
|
||||
Assert.assertTrue(i > 0);
|
||||
|
||||
int j = token.lastIndexOf(".");
|
||||
Assert.assertTrue(j > 0);
|
||||
String claim = new String(org.jose4j.base64url.Base64.decode(token.substring(i+1, j)));
|
||||
Map u = JSONObject.fromObject(claim);
|
||||
Assert.assertEquals(username,u.get("sub"));
|
||||
return token;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package io.jenkins.blueocean.service.embedded;
|
||||
|
||||
import com.mashape.unirest.http.exceptions.UnirestException;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
|
||||
|
@ -11,12 +12,12 @@ import java.util.Map;
|
|||
*/
|
||||
public class OrganizationApiTest extends BaseTest {
|
||||
@Test
|
||||
public void organizationUsers() {
|
||||
public void organizationUsers() throws UnirestException {
|
||||
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||
hudson.model.User alice = j.jenkins.getUser("alice");
|
||||
alice.setFullName("Alice Cooper");
|
||||
|
||||
List users = request().authAlice().get("/organizations/jenkins/users/").build(List.class);
|
||||
List users = request().jwtToken(getJwtToken(j.jenkins,"alice", "alice")).get("/organizations/jenkins/users/").build(List.class);
|
||||
|
||||
Assert.assertEquals(users.size(), 1);
|
||||
Assert.assertEquals(((Map)users.get(0)).get("id"), "alice");
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package io.jenkins.blueocean.service.embedded;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.mashape.unirest.http.exceptions.UnirestException;
|
||||
import hudson.Extension;
|
||||
import hudson.FilePath;
|
||||
import hudson.Launcher;
|
||||
|
@ -550,7 +551,7 @@ public class PipelineApiTest extends BaseTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void PipelineSecureWithLoggedInUserPermissionTest() throws IOException {
|
||||
public void PipelineSecureWithLoggedInUserPermissionTest() throws IOException, UnirestException {
|
||||
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||
|
||||
hudson.model.User user = j.jenkins.getUser("alice");
|
||||
|
@ -560,10 +561,11 @@ public class PipelineApiTest extends BaseTest {
|
|||
MockFolder folder = j.createFolder("folder1");
|
||||
|
||||
Project p = folder.createProject(FreeStyleProject.class, "test1");
|
||||
|
||||
String token = getJwtToken(j.jenkins, "alice", "alice");
|
||||
Assert.assertNotNull(token);
|
||||
Map response = new RequestBuilder(baseUrl)
|
||||
.get("/organizations/jenkins/pipelines/folder1/pipelines/test1")
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.build(Map.class);
|
||||
|
||||
validatePipeline(p, response);
|
||||
|
|
|
@ -6,6 +6,8 @@ import hudson.model.FreeStyleProject;
|
|||
import hudson.model.Project;
|
||||
import hudson.model.User;
|
||||
import hudson.tasks.Mailer;
|
||||
import jenkins.model.Jenkins;
|
||||
import org.acegisecurity.context.SecurityContextHolder;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.jvnet.hudson.test.MockFolder;
|
||||
|
@ -72,19 +74,52 @@ public class ProfileApiTest extends BaseTest{
|
|||
@Test
|
||||
public void patchMimeFailTest() throws Exception {
|
||||
User system = j.jenkins.getUser("SYSTEM");
|
||||
patch("/users/"+system.getId(), "","text/plain", 415);
|
||||
|
||||
new RequestBuilder(baseUrl)
|
||||
.contentType("text/plain")
|
||||
.status(415)
|
||||
.patch("/users/"+system.getId())
|
||||
.build(Map.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getUserDetailsTest() throws Exception {
|
||||
hudson.model.User user = j.jenkins.getUser("alice");
|
||||
user.setFullName("Alice Cooper");
|
||||
user.addProperty(new Mailer.UserProperty("alice@jenkins-ci.org"));
|
||||
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||
hudson.model.User alice = j.jenkins.getUser("alice");
|
||||
alice.setFullName("Alice Cooper");
|
||||
alice.addProperty(new Mailer.UserProperty("alice@jenkins-ci.org"));
|
||||
|
||||
Map response = get("/users/"+user.getId());
|
||||
Assert.assertEquals(user.getId(), response.get("id"));
|
||||
Assert.assertEquals(user.getFullName(), response.get("fullName"));
|
||||
Assert.assertEquals("alice@jenkins-ci.org", response.get("email"));
|
||||
hudson.model.User bob = j.jenkins.getUser("bob");
|
||||
|
||||
bob.setFullName("Bob Smith");
|
||||
bob.addProperty(new Mailer.UserProperty("bob@jenkins-ci.org"));
|
||||
|
||||
//Call is made as anonymous user, email should be null
|
||||
Map response = get("/users/"+alice.getId());
|
||||
Assert.assertEquals(alice.getId(), response.get("id"));
|
||||
Assert.assertEquals(alice.getFullName(), response.get("fullName"));
|
||||
Assert.assertNull(response.get("email"));
|
||||
|
||||
//make a request on bob's behalf to get alice's user details, should get null email
|
||||
Map r = new RequestBuilder(baseUrl)
|
||||
.status(200)
|
||||
.jwtToken(getJwtToken(j.jenkins,"bob", "bob"))
|
||||
.get("/users/"+alice.getId()).build(Map.class);
|
||||
|
||||
Assert.assertEquals(alice.getId(), r.get("id"));
|
||||
Assert.assertEquals(alice.getFullName(), r.get("fullName"));
|
||||
Assert.assertTrue(bob.hasPermission(Jenkins.ADMINISTER));
|
||||
//bob is admin so can see alice email
|
||||
Assert.assertEquals("alice@jenkins-ci.org",r.get("email"));
|
||||
|
||||
r = new RequestBuilder(baseUrl)
|
||||
.status(200)
|
||||
.jwtToken(getJwtToken(j.jenkins,"alice", "alice"))
|
||||
.get("/users/"+alice.getId()).build(Map.class);
|
||||
|
||||
Assert.assertEquals(alice.getId(), r.get("id"));
|
||||
Assert.assertEquals(alice.getFullName(), r.get("fullName"));
|
||||
Assert.assertEquals("alice@jenkins-ci.org",r.get("email"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -95,17 +130,17 @@ public class ProfileApiTest extends BaseTest{
|
|||
|
||||
Project p = j.createFreeStyleProject("pipeline1");
|
||||
|
||||
|
||||
String token = getJwtToken(j.jenkins,"alice", "alice");
|
||||
Map map = new RequestBuilder(baseUrl)
|
||||
.put("/organizations/jenkins/pipelines/pipeline1/favorite")
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", true))
|
||||
.build(Map.class);
|
||||
|
||||
validatePipeline(p, (Map) map.get("item"));
|
||||
List l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(1, l.size());
|
||||
|
@ -117,7 +152,7 @@ public class ProfileApiTest extends BaseTest{
|
|||
Assert.assertEquals("/blue/rest/organizations/jenkins/pipelines/pipeline1/favorite/", href);
|
||||
map = new RequestBuilder(baseUrl)
|
||||
.put(href.substring("/blue/rest".length()))
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", false))
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -125,15 +160,14 @@ public class ProfileApiTest extends BaseTest{
|
|||
|
||||
l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(0, l.size());
|
||||
|
||||
|
||||
new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("bob","bob")
|
||||
.jwtToken(getJwtToken(j.jenkins,"bob","bob"))
|
||||
.status(403)
|
||||
.build(String.class);
|
||||
|
||||
|
@ -148,16 +182,17 @@ public class ProfileApiTest extends BaseTest{
|
|||
MockFolder folder1 = j.createFolder("folder1");
|
||||
Project p = folder1.createProject(FreeStyleProject.class, "pipeline1");
|
||||
|
||||
String token = getJwtToken(j.jenkins,"alice", "alice");
|
||||
Map map = new RequestBuilder(baseUrl)
|
||||
.put("/organizations/jenkins/pipelines/folder1/pipelines/pipeline1/favorite/")
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", true))
|
||||
.build(Map.class);
|
||||
|
||||
validatePipeline(p, (Map) map.get("item"));
|
||||
List l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(1, l.size());
|
||||
|
@ -171,7 +206,7 @@ public class ProfileApiTest extends BaseTest{
|
|||
|
||||
map = new RequestBuilder(baseUrl)
|
||||
.put(href.substring("/blue/rest".length()))
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", false))
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -179,7 +214,7 @@ public class ProfileApiTest extends BaseTest{
|
|||
|
||||
l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(0, l.size());
|
||||
|
@ -187,14 +222,14 @@ public class ProfileApiTest extends BaseTest{
|
|||
|
||||
map = new RequestBuilder(baseUrl)
|
||||
.put("/organizations/jenkins/pipelines/folder1/favorite/")
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", true))
|
||||
.build(Map.class);
|
||||
|
||||
validateFolder(folder1, (Map) map.get("item"));
|
||||
l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(1, l.size());
|
||||
|
@ -208,7 +243,7 @@ public class ProfileApiTest extends BaseTest{
|
|||
|
||||
map = new RequestBuilder(baseUrl)
|
||||
.put(href.substring("/blue/rest".length()))
|
||||
.auth("alice", "alice")
|
||||
.jwtToken(token)
|
||||
.data(ImmutableMap.of("favorite", false))
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -216,7 +251,7 @@ public class ProfileApiTest extends BaseTest{
|
|||
|
||||
l = new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(0, l.size());
|
||||
|
@ -225,7 +260,7 @@ public class ProfileApiTest extends BaseTest{
|
|||
|
||||
new RequestBuilder(baseUrl)
|
||||
.get("/users/"+user.getId()+"/favorites/")
|
||||
.auth("bob","bob")
|
||||
.jwtToken(getJwtToken(j.jenkins,"bob","bob"))
|
||||
.status(403)
|
||||
.build(String.class);
|
||||
|
||||
|
@ -262,9 +297,11 @@ public class ProfileApiTest extends BaseTest{
|
|||
user.setFullName("Alice Cooper");
|
||||
user.addProperty(new Mailer.UserProperty("alice@jenkins-ci.org"));
|
||||
|
||||
String token = getJwtToken(j.jenkins,"alice", "alice");
|
||||
|
||||
Map u = new RequestBuilder(baseUrl)
|
||||
.get("/organizations/jenkins/user/")
|
||||
.auth("alice","alice")
|
||||
.jwtToken(token)
|
||||
.status(200)
|
||||
.build(Map.class);
|
||||
|
||||
|
@ -285,10 +322,50 @@ public class ProfileApiTest extends BaseTest{
|
|||
user1.setFullName("Bob Cooper");
|
||||
user1.addProperty(new Mailer.UserProperty("bob@jenkins-ci.org"));
|
||||
|
||||
new RequestBuilder(baseUrl)
|
||||
Map u = new RequestBuilder(baseUrl)
|
||||
.get("/organizations/jenkins/user/")
|
||||
.status(404)
|
||||
.build(Map.class);
|
||||
.build(Map.class); //sends jwt token for anonymous user
|
||||
}
|
||||
|
||||
@Test
|
||||
public void badTokenTest1() throws Exception {
|
||||
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||
new RequestBuilder(baseUrl)
|
||||
.get("/organizations/jenkins/user/")
|
||||
.jwtToken("")
|
||||
.status(401)
|
||||
.build(Map.class);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void badTokenTest2() throws Exception {
|
||||
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||
new RequestBuilder(baseUrl)
|
||||
.get("/organizations/jenkins/user/")
|
||||
.jwtToken("aasasasas")
|
||||
.status(401)
|
||||
.build(Map.class); }
|
||||
|
||||
|
||||
@Test
|
||||
public void userCurrentTest() throws Exception {
|
||||
j.jenkins.setSecurityRealm(j.createDummySecurityRealm());
|
||||
|
||||
SecurityContextHolder.getContext().setAuthentication(j.jenkins.ANONYMOUS);
|
||||
|
||||
Assert.assertNull(User.current());
|
||||
|
||||
List<Map> l = new RequestBuilder(baseUrl)
|
||||
.get("/organizations/jenkins/pipelines/")
|
||||
.jwtToken(getJwtToken(j.jenkins))
|
||||
.build(List.class);
|
||||
|
||||
Assert.assertEquals(0, l.size());
|
||||
Assert.assertNull(User.current());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
- [Media Type](#media-type)
|
||||
- [Date Format](#date-format)
|
||||
- [Crumbs](#crumbs)
|
||||
- [Security](#security)
|
||||
- [API access from browser with JWT enabled](#api-access-from-browser-with-jwt-enabled)
|
||||
- [Navigability](#navigability)
|
||||
- [Links](#links)
|
||||
- [Resource discovery](#resource-discovery)
|
||||
|
@ -32,6 +34,7 @@
|
|||
- [MultiBranch Pipeline API](#multibranch-pipeline-api)
|
||||
- [Get MultiBranch pipeline](#get-multibranch-pipeline)
|
||||
- [Get MultiBranch pipeline branches](#get-multibranch-pipeline-branches)
|
||||
- [Pipeline Permissions](#pipeline-permissions)
|
||||
- [Queue API](#queue-api)
|
||||
- [Fetch queue for an pipeline](#fetch-queue-for-an-pipeline)
|
||||
- [GET queue for a MultiBranch pipeline](#get-queue-for-a-multibranch-pipeline)
|
||||
|
@ -42,6 +45,7 @@
|
|||
- [Find latest run on all pipelines](#find-latest-run-on-all-pipelines)
|
||||
- [Start a build](#start-a-build)
|
||||
- [Stop a build](#stop-a-build)
|
||||
- [Stop a build as blocking call](#stop-a-build-as-blocking-call)
|
||||
- [Get MultiBranch job's branch run detail](#get-multibranch-jobs-branch-run-detail)
|
||||
- [Get all runs for all branches on a multibranch pipeline (ordered by date)](#get-all-runs-for-all-branches-on-a-multibranch-pipeline-ordered-by-date)
|
||||
- [Get change set for a run](#get-change-set-for-a-run)
|
||||
|
@ -103,7 +107,32 @@ All date formats are in ISO 8601 format
|
|||
|
||||
Jenkins usually requires a "crumb" with posted requests to prevent request forgery and other shenanigans.
|
||||
To avoid needing a crumb to POST data, the header `Content-Type: application/json` *must* be used.
|
||||
|
||||
|
||||
# Security
|
||||
|
||||
BlueOcean REST APIs requires JWT token for authentication. JWT APIs are provided by blueocean-jwt plugin. See
|
||||
[JWT APIs](../blueocean-jwt/README.md) to get JWT token and to get public key needed to verify the claims.
|
||||
|
||||
JWT token must be sent as bearer token as value of HTTP 'Authorization' header:
|
||||
|
||||
curl -H 'Authorization: Bearer eyJraWQ...' http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/
|
||||
|
||||
To disable JWT authentication use DISABLE_BLUEOCEAN_JWT_AUTHENTICATION=true system property.
|
||||
|
||||
mvn hpi:run -DDISABLE_BLUEOCEAN_JWT_AUTHENTICATION=true
|
||||
|
||||
|
||||
## API access from browser with JWT enabled
|
||||
|
||||
Sometimes testing API from browser is desirable. Here are steps to to do that using Postman Chrome app:
|
||||
|
||||
* Install Postman on Chrome (chrome://apps/) or install Postman app on Mac OS (https://www.getpostman.com).
|
||||
* Launch postman
|
||||
* Create a JWT token, see [JWT APIs](../blueocean-jwt/README.md). You can customize expiry time to reuse the fetched token. You may like to save the query in Postman as collection *blueocean*. Anytime later you want to generate token use *blueocean* collection and click send on previous GET.
|
||||
* Click on + on tab and type the API URL, e.g. http://localhost:8080/jenkins/blue/rest/organizations/jenkins/pipelines/, then add header with *Authorization* header with value *Bearer COPIED_JWT_TOKEN*. Use this tab to invoke any Blueocean REST API. You may like to either add to 'blueocean' collection if you like.
|
||||
|
||||
|
||||
|
||||
# Navigability
|
||||
|
||||
## Links
|
||||
|
|
|
@ -6,6 +6,7 @@ process.env.SKIP_BLUE_IMPORTS = 'YES';
|
|||
|
||||
var gi = require('giti');
|
||||
var fs = require('fs');
|
||||
|
||||
var builder = require('@jenkins-cd/js-builder');
|
||||
|
||||
// create a dummy revisionInfo so developmentFooter will not fail
|
||||
|
@ -27,7 +28,11 @@ gi(function (err, result) {
|
|||
// Explicitly setting the src paths in order to allow the rebundle task to
|
||||
// watch for changes in the JDL (js, css, icons etc).
|
||||
// See https://github.com/jenkinsci/js-builder#setting-src-and-test-spec-paths
|
||||
builder.src(['src/main/js', 'src/main/less', 'node_modules/@jenkins-cd/design-language/dist']);
|
||||
builder.src([
|
||||
'src/main/js',
|
||||
'src/main/less',
|
||||
'node_modules/@jenkins-cd/design-language/dist',
|
||||
'node_modules/@jenkins-cd/blueocean-core-js/dist']);
|
||||
|
||||
//
|
||||
// Create the main "App" bundle.
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
"zombie": "^4.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jenkins-cd/blueocean-core-js": "0.0.1-beta5",
|
||||
"@jenkins-cd/blueocean-core-js": "0.0.4",
|
||||
"@jenkins-cd/design-language": "0.0.70",
|
||||
"@jenkins-cd/js-extensions": "0.0.22",
|
||||
"@jenkins-cd/js-modules": "0.0.6",
|
||||
|
|
|
@ -1,35 +1,4 @@
|
|||
const requestDone = 4; // Because Zombie is garbage
|
||||
|
||||
// Basically copied from AjaxHoc
|
||||
function getURL(url, onLoad) {
|
||||
const xmlhttp = new XMLHttpRequest();
|
||||
|
||||
if (!url) {
|
||||
onLoad(null);
|
||||
return;
|
||||
}
|
||||
|
||||
xmlhttp.onreadystatechange = () => {
|
||||
if (xmlhttp.readyState === requestDone) {
|
||||
if (xmlhttp.status === 200) {
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(xmlhttp.responseText);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line
|
||||
console.log('Loading', url,
|
||||
'Expecting JSON, instead got', xmlhttp.responseText);
|
||||
}
|
||||
onLoad(data);
|
||||
} else {
|
||||
// eslint-disable-next-line
|
||||
console.log('Loading', url, 'expected 200, got', xmlhttp.status, xmlhttp.responseText);
|
||||
}
|
||||
}
|
||||
};
|
||||
xmlhttp.open('GET', url, true);
|
||||
xmlhttp.send();
|
||||
}
|
||||
import { Fetch } from '@jenkins-cd/blueocean-core-js';
|
||||
|
||||
exports.initialize = function (oncomplete) {
|
||||
// Get the extension list metadata from Jenkins.
|
||||
|
@ -37,8 +6,9 @@ exports.initialize = function (oncomplete) {
|
|||
const appRoot = document.getElementsByTagName("head")[0].getAttribute("data-appurl");
|
||||
const Extensions = require('@jenkins-cd/js-extensions');
|
||||
Extensions.init({
|
||||
extensionDataProvider: cb => getURL(`${appRoot}/js-extensions`, rsp => cb(rsp.data)),
|
||||
classMetadataProvider: (type, cb) => getURL(`${appRoot}/rest/classes/${type}/`, cb)
|
||||
extensionDataProvider: cb => Fetch.fetchJSON(`${appRoot}/js-extensions`).then(body => cb(body.data)).catch(Fetch.consoleError),
|
||||
classMetadataProvider: (type, cb) => Fetch.fetchJSON(`${appRoot}/rest/classes/${type}/`).then(cb).catch(Fetch.consoleError)
|
||||
});
|
||||
|
||||
oncomplete();
|
||||
};
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
//
|
||||
// Test that we can load the app
|
||||
//
|
||||
|
||||
// See http://zombie.js.org/
|
||||
var Browser = require('zombie');
|
||||
|
||||
describe('blueocean.js', () => {
|
||||
|
||||
it('- test App load', (done) => {
|
||||
var browser = new Browser();
|
||||
var loads = [];
|
||||
|
||||
browser.debug();
|
||||
browser.on('request', (request) => {
|
||||
var url = request.url;
|
||||
loads.push(url);
|
||||
});
|
||||
|
||||
browser.visit('http://localhost:18999/src/test/js/zombie-test-01.html', () => {
|
||||
expect(browser.success).toBe(true);
|
||||
|
||||
// Check the requests are as expected.
|
||||
expect(loads.length).toBe(4);
|
||||
expect(loads[0]).toBe('http://localhost:18999/src/test/js/zombie-test-01.html');
|
||||
expect(loads[1]).toBe('http://localhost:18999/target/classes/io/jenkins/blueocean/no_imports/blueocean.js');
|
||||
|
||||
expect(loads[2]).toBe('http://localhost:18999/src/test/resources/blue/js-extensions');
|
||||
//expect(loads[3]).toBe('http://localhost:18999/src/test/resources/mock-adjuncts/io/jenkins/blueocean-dashboard/jenkins-js-extension.js');
|
||||
|
||||
browser.dump(process.stderr);
|
||||
// Check for some of the elements. We know that the following should
|
||||
// be rendered by the React components.
|
||||
browser.assert.elements('header', 1);
|
||||
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,11 +0,0 @@
|
|||
<html>
|
||||
<head data-resurl="/src/test/resources"
|
||||
data-adjuncturl="/src/test/resources/mock-adjuncts"
|
||||
data-appurl="/src/test/resources/blue"
|
||||
data-rooturl="/src/test/resources">
|
||||
<script type="text/javascript" src="../../../target/classes/io/jenkins/blueocean/no_imports/blueocean.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
8
pom.xml
8
pom.xml
|
@ -106,6 +106,7 @@
|
|||
<module>blueocean-personalization</module>
|
||||
<module>blueocean-plugin</module>
|
||||
<module>blueocean-config</module>
|
||||
<module>blueocean-jwt</module>
|
||||
</modules>
|
||||
|
||||
<repositories>
|
||||
|
@ -185,6 +186,13 @@
|
|||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>blueocean-jwt</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<!-- 3rd party dependencies -->
|
||||
|
||||
<dependency>
|
||||
|
|
Loading…
Reference in New Issue