diff --git a/assets/js/oauth.js b/assets/js/oauth.js
new file mode 100644
index 000000000..8bd7b0423
--- /dev/null
+++ b/assets/js/oauth.js
@@ -0,0 +1,175 @@
+const redirectUri = `${ window.location.origin }/`;
+
+//////////////////////////////////////////////////////////////////////
+// GENERAL HELPER FUNCTIONS
+
+// Make a POST request and parse the response as JSON
+const sendPostRequest = async(url, params) => {
+ let body = Object.keys(params).map(key => key + '=' + params[key]).join('&');
+ const options = {
+ method: 'post',
+ headers: {
+ 'Content-type': 'application/x-www-form-urlencoded; charset=UTF-8'
+ },
+ body
+ }
+ try {
+ const response = await fetch(url, options);
+ const data = await response.json();
+ return data;
+ } catch (err) {
+ console.error('Request failed', err);
+ throw err;
+ }
+}
+// Parse a query string into an object
+const parseQueryString = string => {
+ if(string == "") { return {}; }
+ let segments = string.split("&").map(s => s.split("=") );
+ let queryString = {};
+ segments.forEach(s => queryString[s[0]] = s[1]);
+ return queryString;
+}
+// Get OAuth configuration from OpenID Discovery endpoint
+const getTokenConfiguration = async endpoint => {
+ const options = {
+ method: 'GET',
+ headers: {
+ 'Content-type': 'application/json'
+ }
+ }
+ try {
+ const response = await fetch(endpoint, options);
+ const config = await response.json();
+ return config;
+ } catch (err) {
+ console.error('Request failed', err);
+ throw err;
+ }
+}
+
+//////////////////////////////////////////////////////////////////////
+// PKCE HELPER FUNCTIONS
+
+// Generate a secure random string using the browser crypto functions
+const generateRandomString = () => {
+ var array = new Uint32Array(28);
+ window.crypto.getRandomValues(array);
+ return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join('');
+}
+// Calculate the SHA256 hash of the input text.
+// Returns a promise that resolves to an ArrayBuffer
+const sha256 = plain => {
+ const encoder = new TextEncoder();
+ const data = encoder.encode(plain);
+ return window.crypto.subtle.digest('SHA-256', data);
+}
+// Base64-urlencodes the input string
+const base64urlencode = str => {
+ // Convert the ArrayBuffer to string using Uint8 array to conver to what btoa accepts.
+ // btoa accepts chars only within ascii 0-255 and base64 encodes them.
+ // Then convert the base64 encoded to base64url encoded
+ // (replace + with -, replace / with _, trim trailing =)
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
+ .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+// Return the base64-urlencoded sha256 hash for the PKCE challenge
+const pkceChallengeFromVerifier = async(v) => {
+ let hashed = await sha256(v);
+ return base64urlencode(hashed);
+}
+
+//////////////////////////////////////////////////////////////////////
+// OAUTH REQUEST
+
+// Initiate PKCE Auth Code flow when requested
+const tokenRequest = async({
+ oidcDiscoveryUrl,
+ grantType,
+ authUrl,
+ accessTokenUrl,
+ clientId,
+ scope
+}) => {
+
+ // Check oauth configuration
+ if (oidcDiscoveryUrl !== '') {
+ const { authorization_endpoint, token_endpoint } = await getTokenConfiguration(oidcDiscoveryUrl);
+ authUrl = authorization_endpoint;
+ accessTokenUrl = token_endpoint;
+ }
+
+ // Store oauth information
+ localStorage.setItem('token_endpoint', accessTokenUrl);
+ localStorage.setItem('client_id', clientId);
+
+ // Create and store a random state value
+ const state = generateRandomString();
+ localStorage.setItem('pkce_state', state);
+
+ // Create and store a new PKCE code_verifier (the plaintext random secret)
+ const code_verifier = generateRandomString();
+ localStorage.setItem('pkce_code_verifier', code_verifier);
+
+ // Hash and base64-urlencode the secret to use as the challenge
+ const code_challenge = await pkceChallengeFromVerifier(code_verifier);
+
+ // Build the authorization URL
+ const buildUrl = () => {
+ return authUrl
+ + `?response_type=${grantType}`
+ + '&client_id='+encodeURIComponent(clientId)
+ + '&state='+encodeURIComponent(state)
+ + '&scope='+encodeURIComponent(scope)
+ + '&redirect_uri='+encodeURIComponent(redirectUri)
+ + '&code_challenge='+encodeURIComponent(code_challenge)
+ + '&code_challenge_method=S256'
+ ;
+ }
+
+ // Redirect to the authorization server
+ window.location = buildUrl();
+}
+
+//////////////////////////////////////////////////////////////////////
+// OAUTH REDIRECT HANDLING
+
+// Handle the redirect back from the authorization server and
+// get an access token from the token endpoint
+const oauthRedirect = async() => {
+ let tokenResponse = '';
+ let q = parseQueryString(window.location.search.substring(1));
+ // Check if the server returned an error string
+ if(q.error) {
+ alert('Error returned from authorization server: '+q.error);
+ }
+ // If the server returned an authorization code, attempt to exchange it for an access token
+ if(q.code) {
+ // Verify state matches what we set at the beginning
+ if(localStorage.getItem('pkce_state') != q.state) {
+ alert('Invalid state');
+ } else {
+ try {
+ // Exchange the authorization code for an access token
+ tokenResponse = await sendPostRequest(localStorage.getItem('token_endpoint'), {
+ grant_type: 'authorization_code',
+ code: q.code,
+ client_id: localStorage.getItem('client_id'),
+ redirect_uri: redirectUri,
+ code_verifier: localStorage.getItem('pkce_code_verifier')
+ });
+ } catch (err) {
+ console.log(error.error+'\n\n'+error.error_description);
+ }
+ }
+ // Clean these up since we don't need them anymore
+ localStorage.removeItem('pkce_state');
+ localStorage.removeItem('pkce_code_verifier');
+ localStorage.removeItem('token_endpoint');
+ localStorage.removeItem('client_id');
+ return tokenResponse;
+ }
+ return tokenResponse;
+}
+
+export { tokenRequest, oauthRedirect }
diff --git a/lang/en-US.js b/lang/en-US.js
index 317b9718e..9ad7e21a1 100644
--- a/lang/en-US.js
+++ b/lang/en-US.js
@@ -86,5 +86,25 @@ export default {
connect: "Connect",
disconnect: "Disconnect",
start: "Start",
- stop: "Stop"
+ stop: "Stop",
+ access_token: "Access Token",
+ token_list: "Token List",
+ get_token: "Get New Token",
+ manage_token: "Manage Access Token",
+ save_token: "Save Access Token",
+ use_token: "Use Saved Token",
+ request_token: "Request Token",
+ save_token_req: "Save Token Request",
+ manage_token_req: "Manage Token Request",
+ use_token_req: "Use Token Request",
+ token_req_name: "Request Name",
+ token_req_details: "Request Details",
+ token_name: "Token Name",
+ oidc_discovery_url: "OIDC Discovery URL",
+ auth_url: "Auth URL",
+ access_token_url: "Access Token URL",
+ client_id: "Client ID",
+ scope: "Scope",
+ state: "State",
+ token_req_list: "Token Request List"
};
diff --git a/package-lock.json b/package-lock.json
index a17a13373..2b47598bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "postwoman",
- "version": "1.0.0",
+ "version": "1.5.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
diff --git a/pages/index.vue b/pages/index.vue
index 39b3131f7..c2af4dd4b 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -388,11 +388,29 @@
@@ -404,6 +422,121 @@
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
@@ -805,6 +938,164 @@
+
+
+
+
+ -
+
+
{{ $t("manage_token") }}
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
{{ $t("manage_token_req") }}
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -818,6 +1109,7 @@ import parseCurlCommand from "../assets/js/curlparser.js";
import getEnvironmentVariablesFromScript from "../functions/preRequest";
import parseTemplateString from "../functions/templating";
import AceEditor from "../components/ace-editor";
+import { tokenRequest, oauthRedirect } from "../assets/js/oauth";
const statusCategories = [
{
@@ -901,6 +1193,9 @@ export default {
previewEnabled: false,
paramsWatchEnabled: true,
expandResponse: false,
+ showTokenList: false,
+ showTokenRequest: false,
+ showTokenRequestList: false,
/**
* These are content types that can be automatically
@@ -1208,6 +1503,94 @@ export default {
this.$store.commit("setState", { value, attribute: "bearerToken" });
}
},
+ tokens: {
+ get() {
+ return this.$store.state.oauth2.tokens;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "tokens" });
+ }
+ },
+ tokenReqs: {
+ get() {
+ return this.$store.state.oauth2.tokenReqs;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "tokenReqs" });
+ }
+ },
+ tokenReqSelect: {
+ get() {
+ return this.$store.state.oauth2.tokenReqSelect;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "tokenReqSelect" });
+ }
+ },
+ tokenReqName: {
+ get() {
+ return this.$store.state.oauth2.tokenReqName;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "tokenReqName" });
+ }
+ },
+ accessTokenName: {
+ get() {
+ return this.$store.state.oauth2.accessTokenName;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "accessTokenName" });
+ }
+ },
+ oidcDiscoveryUrl: {
+ get() {
+ return this.$store.state.oauth2.oidcDiscoveryUrl;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "oidcDiscoveryUrl" });
+ }
+ },
+ authUrl: {
+ get() {
+ return this.$store.state.oauth2.authUrl;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "authUrl" });
+ }
+ },
+ accessTokenUrl: {
+ get() {
+ return this.$store.state.oauth2.accessTokenUrl;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "accessTokenUrl" });
+ }
+ },
+ clientId: {
+ get() {
+ return this.$store.state.oauth2.clientId;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "clientId" });
+ }
+ },
+ scope: {
+ get() {
+ return this.$store.state.oauth2.scope;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "scope" });
+ }
+ },
+ state: {
+ get() {
+ return this.$store.state.oauth2.state;
+ },
+ set(value) {
+ this.$store.commit("setOAuth2", { value, attribute: "state" });
+ }
+ },
headers: {
get() {
return this.$store.state.request.headers;
@@ -1498,6 +1881,16 @@ export default {
}
return requestString.join("").slice(0, -2);
}
+ },
+ tokenReqDetails() {
+ const details = {
+ oidcDiscoveryUrl: this.oidcDiscoveryUrl,
+ authUrl: this.authUrl,
+ accessTokenUrl: this.accessTokenUrl,
+ clientId: this.clientId,
+ scope: this.scope
+ };
+ return JSON.stringify(details, null, 2);
}
},
methods: {
@@ -2047,6 +2440,9 @@ export default {
this.httpUser = "";
this.httpPassword = "";
this.bearerToken = "";
+ this.showTokenRequest = false;
+ this.tokens = [];
+ this.tokenReqs = [];
break;
case "headers":
this.headers = [];
@@ -2054,6 +2450,19 @@ export default {
case "parameters":
this.params = [];
break;
+ case "access_token":
+ this.accessTokenName = "";
+ this.oidcDiscoveryUrl = "";
+ this.authUrl = "";
+ this.accessTokenUrl = "";
+ this.clientId = "";
+ this.scope = "";
+ break;
+ case "tokens":
+ this.tokens = [];
+ break;
+ case "tokenReqs":
+ this.tokenReqs = [];
default:
(this.label = ""),
(this.method = "GET"),
@@ -2068,6 +2477,15 @@ export default {
this.params = [];
this.bodyParams = [];
this.rawParams = "";
+ this.showTokenRequest = false;
+ this.tokens = [];
+ this.tokenReqs = [];
+ this.accessTokenName = "";
+ this.oidcDiscoveryUrl = "";
+ this.authUrl = "";
+ this.accessTokenUrl = "";
+ this.clientId = "";
+ this.scope = "";
}
e.target.innerHTML = this.doneButton;
this.$toast.info("Cleared", {
@@ -2152,9 +2570,115 @@ export default {
icon: "attach_file"
});
}
+ },
+ async handleAccessTokenRequest() {
+ if (this.oidcDiscoveryUrl === "" && (this.authUrl === "" || this.accessTokenUrl === "")) {
+ this.$toast.error("Please complete configuration urls.", {
+ icon: "error"
+ });
+ return;
+ }
+ try {
+ const tokenReqParams = {
+ grantType: "code",
+ oidcDiscoveryUrl: this.oidcDiscoveryUrl,
+ authUrl: this.authUrl,
+ accessTokenUrl: this.accessTokenUrl,
+ clientId: this.clientId,
+ scope: this.scope
+ };
+ await tokenRequest(tokenReqParams);
+ } catch (e) {
+ this.$toast.error(e, {
+ icon: "code"
+ });
+ }
+ },
+ async oauthRedirectReq() {
+ let tokenInfo = await oauthRedirect();
+ if(tokenInfo.hasOwnProperty('access_token')) {
+ this.bearerToken = tokenInfo.access_token;
+ this.addOAuthToken({
+ name: this.accessTokenName,
+ value: tokenInfo.access_token
+ });
+ }
+ },
+ addOAuthToken({name, value}) {
+ this.$store.commit("addOAuthToken", {
+ name,
+ value
+ });
+ return false;
+ },
+ removeOAuthToken(index) {
+ const oldTokens = this.tokens.slice();
+ this.$store.commit("removeOAuthToken", index);
+ this.$toast.error("Deleted", {
+ icon: "delete",
+ action: {
+ text: "Undo",
+ onClick: (e, toastObject) => {
+ this.tokens = oldTokens;
+ toastObject.remove();
+ }
+ }
+ });
+ },
+ useOAuthToken(value) {
+ this.bearerToken = value;
+ this.showTokenList = false;
+ },
+ addOAuthTokenReq() {
+ try {
+ const name = this.tokenReqName;
+ const details = JSON.parse(this.tokenReqDetails);
+ this.$store.commit("addOAuthTokenReq", {
+ name,
+ details
+ });
+ this.$toast.info("Token request saved");
+ this.showTokenRequestList = false;
+ } catch (e) {
+ this.$toast.error(e, {
+ icon: "code"
+ });
+ }
+ },
+ removeOAuthTokenReq(index) {
+ const oldTokenReqs = this.tokenReqs.slice();
+ let targetReqIndex = this.tokenReqs.findIndex(tokenReq => tokenReq.name === this.tokenReqName);
+ if (targetReqIndex < 0) return;
+ this.$store.commit("removeOAuthTokenReq", targetReqIndex);
+ this.$toast.error("Deleted", {
+ icon: "delete",
+ action: {
+ text: "Undo",
+ onClick: (e, toastObject) => {
+ this.tokenReqs = oldTokenReqs;
+ toastObject.remove();
+ }
+ }
+ });
+ },
+ tokenReqChange(event) {
+ let targetReq = this.tokenReqs.find(tokenReq => tokenReq.name === event.target.value);
+ let {
+ oidcDiscoveryUrl,
+ authUrl,
+ accessTokenUrl,
+ clientId,
+ scope
+ } = targetReq.details;
+ this.tokenReqName = targetReq.name;
+ this.oidcDiscoveryUrl = oidcDiscoveryUrl;
+ this.authUrl = authUrl;
+ this.accessTokenUrl = accessTokenUrl;
+ this.clientId = clientId;
+ this.scope = scope;
}
},
- mounted() {
+ async mounted() {
this.observeRequestButton();
this._keyListener = function(e) {
if (e.key === "g" && (e.ctrlKey || e.metaKey)) {
@@ -2172,6 +2696,7 @@ export default {
}
};
document.addEventListener("keydown", this._keyListener.bind(this));
+ await this.oauthRedirectReq();
},
created() {
this.urlExcludes = this.$store.state.postwoman.settings.URL_EXCLUDES || {
diff --git a/store/mutations.js b/store/mutations.js
index 61bfd4757..64d178f57 100644
--- a/store/mutations.js
+++ b/store/mutations.js
@@ -85,5 +85,29 @@ export default {
setValueBodyParams({ request }, { index, value }) {
request.bodyParams[index].value = value;
+ },
+
+ setOAuth2({ oauth2 }, { attribute, value }) {
+ oauth2[attribute] = value;
+ },
+
+ addOAuthToken({ oauth2 }, value) {
+ oauth2.tokens.push(value);
+ },
+
+ removeOAuthToken({ oauth2 }, index) {
+ oauth2.tokens.splice(index, 1);
+ },
+
+ setOAuthTokenName({ oauth2 }, { index, value }) {
+ oauth2.tokens[index].name = value;
+ },
+
+ addOAuthTokenReq({ oauth2 }, value) {
+ oauth2.tokenReqs.push(value);
+ },
+
+ removeOAuthTokenReq({ oauth2 }, index) {
+ oauth2.tokenReqs.splice(index, 1);
}
};
diff --git a/store/state.js b/store/state.js
index 3f8a25da1..e6ab3b51d 100644
--- a/store/state.js
+++ b/store/state.js
@@ -22,5 +22,17 @@ export default () => ({
headers: [],
variables: [],
query: ""
+ },
+ oauth2: {
+ tokens: [],
+ tokenReqs: [],
+ tokenReqSelect: "",
+ tokenReqName: "",
+ accessTokenName: "",
+ oidcDiscoveryUrl: "",
+ authUrl: "",
+ accessTokenUrl: "",
+ clientId: "",
+ scope: ""
}
});