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 @@
+ + +
+ +
+
+ + +
+
+
+ + +
+ +
+
+ + + +
+
+
+ + + + +
+
+
+ @@ -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: "" } });