Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 23:02:30 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 23:02:30 +0300
commit41fe97390ceddf945f3d967b8fdb3de4c66b7dea (patch)
tree9c8d89a8624828992f06d892cd2f43818ff5dcc8 /app/assets/javascripts/jira_connect
parent0804d2dc31052fb45a1efecedc8e06ce9bc32862 (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.vue26
-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.vue124
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/components/user_link.vue23
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/constants.js21
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/index.js10
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pages/sign_in.vue39
-rw-r--r--app/assets/javascripts/jira_connect/subscriptions/pkce.js60
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;
+}