diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-18 23:02:30 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-18 23:02:30 +0300 |
commit | 41fe97390ceddf945f3d967b8fdb3de4c66b7dea (patch) | |
tree | 9c8d89a8624828992f06d892cd2f43818ff5dcc8 /app/assets/javascripts/jira_connect | |
parent | 0804d2dc31052fb45a1efecedc8e06ce9bc32862 (diff) |
Add latest changes from gitlab-org/gitlab@14-9-stable-eev14.9.0-rc42
Diffstat (limited to 'app/assets/javascripts/jira_connect')
-rw-r--r-- | app/assets/javascripts/jira_connect/subscriptions/components/app.vue | 26 | ||||
-rw-r--r-- | app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue (renamed from app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue) | 4 | ||||
-rw-r--r-- | app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue | 124 | ||||
-rw-r--r-- | app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue | 23 | ||||
-rw-r--r-- | app/assets/javascripts/jira_connect/subscriptions/constants.js | 21 | ||||
-rw-r--r-- | app/assets/javascripts/jira_connect/subscriptions/index.js | 10 | ||||
-rw-r--r-- | app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue | 39 | ||||
-rw-r--r-- | app/assets/javascripts/jira_connect/subscriptions/pkce.js | 60 |
8 files changed, 291 insertions, 16 deletions
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue index 905e242e977..afdb414e82c 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -3,6 +3,7 @@ import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { mapState, mapMutations } from 'vuex'; import { retrieveAlert } from '~/jira_connect/subscriptions/utils'; +import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '../constants'; import { SET_ALERT } from '../store/mutation_types'; import SignInPage from '../pages/sign_in.vue'; import SubscriptionsPage from '../pages/subscriptions.vue'; @@ -28,6 +29,11 @@ export default { default: [], }, }, + data() { + return { + user: null, + }; + }, computed: { ...mapState(['alert']), shouldShowAlert() { @@ -37,7 +43,7 @@ export default { return !isEmpty(this.subscriptions); }, userSignedIn() { - return Boolean(!this.usersPath); + return Boolean(!this.usersPath || this.user); }, }, created() { @@ -51,6 +57,15 @@ export default { const { linkUrl, title, message, variant } = retrieveAlert() || {}; this.setAlert({ linkUrl, title, message, variant }); }, + onSignInOauth(user) { + this.user = user; + }, + onSignInError() { + this.setAlert({ + message: I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE, + variant: 'danger', + }); + }, }, }; </script> @@ -78,11 +93,16 @@ export default { </template> </gl-alert> - <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" /> + <user-link :user-signed-in="userSignedIn" :has-subscriptions="hasSubscriptions" :user="user" /> <h2 class="gl-text-center gl-mb-7">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> <div class="gl-layout-w-limited gl-mx-auto gl-px-5 gl-mb-7"> - <sign-in-page v-if="!userSignedIn" :has-subscriptions="hasSubscriptions" /> + <sign-in-page + v-if="!userSignedIn" + :has-subscriptions="hasSubscriptions" + @sign-in-oauth="onSignInOauth" + @error="onSignInError" + /> <subscriptions-page v-else :has-subscriptions="hasSubscriptions" /> </div> </div> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue index 627abcdd4a0..ec718d5b3ca 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_button.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_legacy_button.vue @@ -1,7 +1,7 @@ <script> import { GlButton } from '@gitlab/ui'; import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils'; -import { s__ } from '~/locale'; +import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants'; export default { components: { @@ -27,7 +27,7 @@ export default { }, }, i18n: { - defaultButtonText: s__('Integrations|Sign in to GitLab'), + defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, }, }; </script> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue new file mode 100644 index 00000000000..d7ec909cb28 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/sign_in_oauth_button.vue @@ -0,0 +1,124 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { + I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, + OAUTH_WINDOW_OPTIONS, + PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM, +} from '~/jira_connect/subscriptions/constants'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import AccessorUtilities from '~/lib/utils/accessor'; + +import { createCodeVerifier, createCodeChallenge } from '../pkce'; + +export default { + components: { + GlButton, + }, + inject: ['oauthMetadata'], + data() { + return { + token: null, + loading: false, + codeVerifier: null, + canUseCrypto: AccessorUtilities.canUseCrypto(), + }; + }, + mounted() { + window.addEventListener('message', this.handleWindowMessage); + }, + beforeDestroy() { + window.removeEventListener('message', this.handleWindowMessage); + }, + methods: { + async startOAuthFlow() { + this.loading = true; + + // Generate state necessary for PKCE OAuth flow + this.codeVerifier = createCodeVerifier(); + const codeChallenge = await createCodeChallenge(this.codeVerifier); + + // Build the initial OAuth authorization URL + const { oauth_authorize_url: oauthAuthorizeURL } = this.oauthMetadata; + const oauthAuthorizeURLWithChallenge = setUrlParams( + { + code_challenge: codeChallenge, + code_challenge_method: PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.short, + }, + oauthAuthorizeURL, + ); + + window.open( + oauthAuthorizeURLWithChallenge, + this.$options.i18n.defaultButtonText, + OAUTH_WINDOW_OPTIONS, + ); + }, + async handleWindowMessage(event) { + if (window.origin !== event.origin) { + this.loading = false; + this.handleError(); + return; + } + + // Verify that OAuth state isn't altered. + const state = event.data?.state; + if (state !== this.oauthMetadata.state) { + this.loading = false; + this.handleError(); + return; + } + + // Request access token and load the authenticated user. + const code = event.data?.code; + try { + const accessToken = await this.getOAuthToken(code); + await this.loadUser(accessToken); + } catch (e) { + this.handleError(); + } finally { + this.loading = false; + } + }, + handleError() { + this.$emit('error'); + }, + async getOAuthToken(code) { + const { + oauth_token_payload: oauthTokenPayload, + oauth_token_url: oauthTokenURL, + } = this.oauthMetadata; + const { data } = await axios.post(oauthTokenURL, { + ...oauthTokenPayload, + code, + code_verifier: this.codeVerifier, + }); + + return data.access_token; + }, + async loadUser(accessToken) { + const { data } = await axios.get('/api/v4/user', { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + this.$emit('sign-in', data); + }, + }, + i18n: { + defaultButtonText: I18N_DEFAULT_SIGN_IN_BUTTON_TEXT, + }, +}; +</script> +<template> + <gl-button + category="primary" + variant="info" + :loading="loading" + :disabled="!canUseCrypto" + @click="startOAuthFlow" + > + <slot> + {{ $options.i18n.defaultButtonText }} + </slot> + </gl-button> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue index fad3d2616d8..5e2c83aff65 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue @@ -25,6 +25,11 @@ export default { type: Boolean, required: true, }, + user: { + type: Object, + required: false, + default: null, + }, }, data() { return { @@ -32,8 +37,19 @@ export default { }; }, computed: { + gitlabUserName() { + return gon.current_username ?? this.user?.username; + }, gitlabUserHandle() { - return `@${gon.current_username}`; + return this.gitlabUserName ? `@${this.gitlabUserName}` : undefined; + }, + gitlabUserLink() { + return this.gitlabUserPath ?? `${gon.relative_root_url}/${this.gitlabUserName}`; + }, + signedInText() { + return this.gitlabUserHandle + ? this.$options.i18n.signedInAsUserText + : this.$options.i18n.signedInText; }, }, async created() { @@ -42,14 +58,15 @@ export default { i18n: { signInText: __('Sign in to GitLab'), signedInAsUserText: __('Signed in to GitLab as %{user_link}'), + signedInText: __('Signed in to GitLab'), }, }; </script> <template> <div class="jira-connect-user gl-font-base"> - <gl-sprintf v-if="userSignedIn" :message="$options.i18n.signedInAsUserText"> + <gl-sprintf v-if="userSignedIn" :message="signedInText"> <template #user_link> - <gl-link data-testid="gitlab-user-link" :href="gitlabUserPath" target="_blank"> + <gl-link data-testid="gitlab-user-link" :href="gitlabUserLink" target="_blank"> {{ gitlabUserHandle }} </gl-link> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 2a65b7bc1fa..d30ebdbb487 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -1,5 +1,26 @@ +import { s__ } from '~/locale'; + export const DEFAULT_GROUPS_PER_PAGE = 10; export const ALERT_LOCALSTORAGE_KEY = 'gitlab_alert'; export const MINIMUM_SEARCH_TERM_LENGTH = 3; export const ADD_NAMESPACE_MODAL_ID = 'add-namespace-modal'; + +export const I18N_DEFAULT_SIGN_IN_BUTTON_TEXT = s__('Integrations|Sign in to GitLab'); +export const I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE = s__('Integrations|Failed to sign in to GitLab.'); + +const OAUTH_WINDOW_SIZE = 800; +export const OAUTH_WINDOW_OPTIONS = [ + 'resizable=yes', + 'scrollbars=yes', + 'status=yes', + `width=${OAUTH_WINDOW_SIZE}`, + `height=${OAUTH_WINDOW_SIZE}`, + `left=${window.screen.width / 2 - OAUTH_WINDOW_SIZE / 2}`, + `top=${window.screen.height / 2 - OAUTH_WINDOW_SIZE / 2}`, +].join(','); + +export const PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM = { + long: 'SHA-256', + short: 'S256', +}; diff --git a/app/assets/javascripts/jira_connect/subscriptions/index.js b/app/assets/javascripts/jira_connect/subscriptions/index.js index cd1fc1d4455..320f0f8aa6c 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/index.js +++ b/app/assets/javascripts/jira_connect/subscriptions/index.js @@ -21,7 +21,14 @@ export function initJiraConnect() { Vue.use(Translate); Vue.use(GlFeatureFlagsPlugin); - const { groupsPath, subscriptions, subscriptionsPath, usersPath, gitlabUserPath } = el.dataset; + const { + groupsPath, + subscriptions, + subscriptionsPath, + usersPath, + gitlabUserPath, + oauthMetadata, + } = el.dataset; sizeToParent(); return new Vue({ @@ -33,6 +40,7 @@ export function initJiraConnect() { subscriptionsPath, usersPath, gitlabUserPath, + oauthMetadata: oauthMetadata ? JSON.parse(oauthMetadata) : null, }, render(createElement) { return createElement(JiraConnectApp); diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue index 2bce5afc72b..a24ee33b723 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue @@ -1,14 +1,17 @@ <script> import { s__ } from '~/locale'; + +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import SubscriptionsList from '../components/subscriptions_list.vue'; -import SignInButton from '../components/sign_in_button.vue'; export default { name: 'SignInPage', components: { SubscriptionsList, - SignInButton, + SignInLegacyButton: () => import('../components/sign_in_legacy_button.vue'), + SignInOauthButton: () => import('../components/sign_in_oauth_button.vue'), }, + mixins: [glFeatureFlagMixin()], inject: ['usersPath'], props: { hasSubscriptions: { @@ -16,25 +19,47 @@ export default { required: true, }, }, + computed: { + useSignInOauthButton() { + return this.glFeatures.jiraConnectOauth; + }, + }, i18n: { - signinButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), + signInButtonTextWithSubscriptions: s__('Integrations|Sign in to add namespaces'), signInText: s__('JiraService|Sign in to GitLab.com to get started.'), }, + methods: { + onSignInError() { + this.$emit('error'); + }, + }, }; </script> <template> <div v-if="hasSubscriptions"> <div class="gl-display-flex gl-justify-content-end"> - <sign-in-button :users-path="usersPath"> - {{ $options.i18n.signinButtonTextWithSubscriptions }} - </sign-in-button> + <sign-in-oauth-button + v-if="useSignInOauthButton" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + > + {{ $options.i18n.signInButtonTextWithSubscriptions }} + </sign-in-oauth-button> + <sign-in-legacy-button v-else :users-path="usersPath"> + {{ $options.i18n.signInButtonTextWithSubscriptions }} + </sign-in-legacy-button> </div> <subscriptions-list /> </div> <div v-else class="gl-text-center"> <p class="gl-mb-7">{{ $options.i18n.signInText }}</p> - <sign-in-button class="gl-mb-7" :users-path="usersPath" /> + <sign-in-oauth-button + v-if="useSignInOauthButton" + @sign-in="$emit('sign-in-oauth', $event)" + @error="onSignInError" + /> + <sign-in-legacy-button v-else class="gl-mb-7" :users-path="usersPath" /> </div> </template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/pkce.js b/app/assets/javascripts/jira_connect/subscriptions/pkce.js new file mode 100644 index 00000000000..18ea5cae860 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/pkce.js @@ -0,0 +1,60 @@ +import { bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util'; +import { PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM } from './constants'; + +// PKCE codeverifier should have a maximum length of 128 characters. +// Using 96 bytes generates a string of 128 characters. +// RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 +export const CODE_VERIFIER_BYTES = 96; + +/** + * Generate a cryptographically random string. + * @param {Number} lengthBytes + * @returns {String} a random string + */ +function getRandomString(lengthBytes) { + // generate random values and load them into byteArray. + const byteArray = new Uint8Array(lengthBytes); + window.crypto.getRandomValues(byteArray); + + // Convert array to string + const randomString = bufferToBase64(byteArray); + return randomString; +} + +/** + * Creates a code verifier to be used for OAuth PKCE authentication. + * The code verifier has 128 characters. + * + * RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 + * @returns {String} code verifier + */ +export function createCodeVerifier() { + const verifier = getRandomString(CODE_VERIFIER_BYTES); + return base64ToBase64Url(verifier); +} + +/** + * Creates a code challenge for OAuth PKCE authentication. + * The code challenge is derived from the given [codeVerifier]. + * [codeVerifier] is tranformed in the following way (as per the RFC): + * code_challenge = BASE64URL-ENCODE(SHA256(ASCII(codeVerifier))) + * + * RFC: https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + * @param {String} codeVerifier + * @returns {String} code challenge + */ +export async function createCodeChallenge(codeVerifier) { + // Generate SHA-256 digest of the [codeVerifier] + const buffer = new TextEncoder().encode(codeVerifier); + const digestArrayBuffer = await window.crypto.subtle.digest( + PKCE_CODE_CHALLENGE_DIGEST_ALGORITHM.long, + buffer, + ); + + // Convert digest to a Base64URL-encoded string + const digestHash = bufferToBase64(digestArrayBuffer); + // Escape string to remove reserved charaters + const codeChallenge = base64ToBase64Url(digestHash); + + return codeChallenge; +} |