Commit 908f29f9 authored by Luke Duncalfe's avatar Luke Duncalfe

Merge branch '284211_backend-jira-connect-oauth' into 'master'

Jira Connect App: Support OAuth w/ PKCE flow with GitLab

See merge request gitlab-org/gitlab!81126
parents 3235302b 20dbaecb
......@@ -45,6 +45,17 @@ export const bufferToBase64 = (input) => {
return btoa(String.fromCharCode(...arr));
};
/**
* Return a URL-safe base64 string.
*
* RFC: https://datatracker.ietf.org/doc/html/rfc4648#section-5
* @param {String} base64Str
* @returns {String}
*/
export const base64ToBase64Url = (base64Str) => {
return base64Str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
};
/**
* Returns a copy of the given object with the id property converted to buffer
*
......
......@@ -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>
......
<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>
......
<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>
......@@ -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>
......
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',
};
......@@ -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);
......
<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>
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;
}
......@@ -50,8 +50,16 @@ function canUseLocalStorage() {
return safe;
}
/**
* Determines if `window.crypto` is available.
*/
function canUseCrypto() {
return window.crypto?.subtle !== undefined;
}
const AccessorUtilities = {
canUseLocalStorage,
canUseCrypto,
};
export default AccessorUtilities;
function getOriginURL() {
const origin = new URL(window.opener.location);
origin.hash = '';
origin.search = '';
return origin;
}
function postMessageToJiraConnectApp(data) {
window.opener.postMessage(data, getOriginURL().toString());
}
function initOAuthCallbacks() {
const params = new URLSearchParams(window.location.search);
if (params.has('code') && params.has('state')) {
postMessageToJiraConnectApp({
success: true,
code: params.get('code'),
state: params.get('state'),
});
} else {
postMessageToJiraConnectApp({ success: false });
}
window.close();
}
initOAuthCallbacks();
# frozen_string_literal: true
# This controller's role is to serve as a landing page
# that users get redirected to after installing and authenticating
# The GitLab.com for Jira App (https://marketplace.atlassian.com/apps/1221011/gitlab-com-for-jira-cloud)
#
class JiraConnect::OauthCallbacksController < ApplicationController
feature_category :integrations
def index; end
end
......@@ -16,6 +16,10 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController
p.style_src(*style_src_values)
end
before_action do
push_frontend_feature_flag(:jira_connect_oauth, @user, default_enabled: :yaml)
end
before_action :allow_rendering_in_iframe, only: :index
before_action :verify_qsh_claim!, only: :index
before_action :authenticate_user!, only: :create
......
......@@ -9,12 +9,38 @@ module JiraConnectHelper
subscriptions: subscriptions.map { |s| serialize_subscription(s) }.to_json,
subscriptions_path: jira_connect_subscriptions_path,
users_path: current_user ? nil : jira_connect_users_path, # users_path is used to determine if user is signed in
gitlab_user_path: current_user ? user_path(current_user) : nil
gitlab_user_path: current_user ? user_path(current_user) : nil,
oauth_metadata: Feature.enabled?(:jira_connect_oauth, current_user) ? jira_connect_oauth_data.to_json : nil
}
end
private
def jira_connect_oauth_data
oauth_authorize_url = oauth_authorization_url(
client_id: ENV['JIRA_CONNECT_OAUTH_CLIENT_ID'],
response_type: 'code',
scope: 'api',
redirect_uri: jira_connect_oauth_callbacks_url,
state: oauth_state
)
{
oauth_authorize_url: oauth_authorize_url,
oauth_token_url: oauth_token_url,
state: oauth_state,
oauth_token_payload: {
grant_type: :authorization_code,
client_id: ENV['JIRA_CONNECT_OAUTH_CLIENT_ID'],
redirect_uri: jira_connect_oauth_callbacks_url
}
}
end
def oauth_state
@oauth_state ||= SecureRandom.hex(32)
end
def serialize_subscription(subscription)
{
group: {
......
%p= s_('Integrations|You can close this window.')
---
name: jira_connect_oauth
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81126
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/355048
milestone: '14.9'
type: development
group: group::integrations
default_enabled: false
......@@ -20,4 +20,6 @@ namespace :jira_connect do
put :update
end
end
resources :oauth_callbacks, only: [:index]
end
......@@ -74,3 +74,45 @@ If you use Gitpod and you get an error about Jira not being able to access the d
1. When the GDK is running, select **Ports** in the bottom-right corner.
1. On the left sidebar, select the port the GDK is listening to (typically `3000`).
1. If the port is marked as private, select the lock icon to make it public.
## Test the GitLab OAuth authentication flow
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81126) in GitLab 14.9 [with a flag](../../administration/feature_flags.md) named `jira_connect_oauth`. Disabled by default.
GitLab for Jira users can authenticate with GitLab using GitLab OAuth.
FLAG:
By default this feature is not available. To make it available,
ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `jira_connect_oauth`.
The feature is not ready for production use.
The following steps describe setting up an environment to test the GitLab OAuth flow:
1. Start a Gitpod session and open the rails console.
```shell
bundle exec rails console
```
1. Enable the feature flag.
```shell
Feature.enable(:jira_connect_oauth)
```
1. On your GitLab instance, go to **Admin > Applications**.
1. Create a new application with the following settings:
- Name: `Jira Connect`
- Redirect URI: `YOUR_GITPOD_INSTANCE/-/jira_connect/oauth_callbacks`
- Scopes: `api`
- Trusted: **No**
- Confidential: **No**
1. Copy the Application ID.
1. Go to [gitpod.io/variables](https://gitpod.io/variables).
1. Create a new variable named `JIRA_CONNECT_OAUTH_CLIENT_ID`, with a scope of `*/*`, and paste the Application ID as the value.
If you already have an active Gitpod instance, use the following command in the Gitpod terminal to set the environment variable:
```shell
eval $(gp env -e JIRA_CONNECT_OAUTH_CLIENT_ID=$YOUR_APPLICATION_ID)
```
......@@ -19843,6 +19843,9 @@ msgstr ""
msgid "Integrations|Failed to load namespaces. Please try again."
msgstr ""
msgid "Integrations|Failed to sign in to GitLab."
msgstr ""
msgid "Integrations|Failed to unlink namespace. Please try again."
msgstr ""
......@@ -19957,6 +19960,9 @@ msgstr ""
msgid "Integrations|Use default settings"
msgstr ""
msgid "Integrations|You can close this window."
msgstr ""
msgid "Integrations|You can now close this window and return to the GitLab for Jira application."
msgstr ""
......@@ -34205,6 +34211,9 @@ msgstr ""
msgid "Signed in"
msgstr ""
msgid "Signed in to GitLab"
msgstr ""
msgid "Signed in to GitLab as %{user_link}"
msgstr ""
......
import { base64ToBuffer, bufferToBase64 } from '~/authentication/webauthn/util';
import { base64ToBuffer, bufferToBase64, base64ToBase64Url } from '~/authentication/webauthn/util';
const encodedString = 'SGVsbG8gd29ybGQh';
const stringBytes = [72, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33];
......@@ -16,4 +16,19 @@ describe('Webauthn utils', () => {
const buffer = base64ToBuffer(encodedString);
expect(bufferToBase64(buffer)).toBe(encodedString);
});
describe('base64ToBase64Url', () => {
it.each`
argument | expectedResult
${'asd+'} | ${'asd-'}
${'asd/'} | ${'asd_'}
${'asd='} | ${'asd'}
${'+asd'} | ${'-asd'}
${'/asd'} | ${'_asd'}
${'=asd'} | ${'=asd'}
${'a+bc/def=ghigjk=='} | ${'a-bc_def=ghigjk'}
`('returns $expectedResult when argument is $argument', ({ argument, expectedResult }) => {
expect(base64ToBase64Url(argument)).toBe(expectedResult);
});
});
});
......@@ -8,6 +8,7 @@ import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.
import UserLink from '~/jira_connect/subscriptions/components/user_link.vue';
import createStore from '~/jira_connect/subscriptions/store';
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants';
import { __ } from '~/locale';
import { mockSubscription } from '../mock_data';
......@@ -24,6 +25,7 @@ describe('JiraConnectApp', () => {
const findAlertLink = () => findAlert().findComponent(GlLink);
const findSignInPage = () => wrapper.findComponent(SignInPage);
const findSubscriptionsPage = () => wrapper.findComponent(SubscriptionsPage);
const findUserLink = () => wrapper.findComponent(UserLink);
const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => {
store = createStore();
......@@ -78,10 +80,11 @@ describe('JiraConnectApp', () => {
},
});
const userLink = wrapper.findComponent(UserLink);
const userLink = findUserLink();
expect(userLink.exists()).toBe(true);
expect(userLink.props()).toEqual({
hasSubscriptions: false,
user: null,
userSignedIn: false,
});
});
......@@ -153,4 +156,55 @@ describe('JiraConnectApp', () => {
});
});
});
describe('when user signed out', () => {
describe('when sign in page emits `sign-in-oauth` event', () => {
const mockUser = { name: 'test' };
beforeEach(async () => {
createComponent({
provide: {
usersPath: '/mock',
subscriptions: [],
},
});
findSignInPage().vm.$emit('sign-in-oauth', mockUser);
await nextTick();
});
it('hides sign in page and renders subscriptions page', () => {
expect(findSignInPage().exists()).toBe(false);
expect(findSubscriptionsPage().exists()).toBe(true);
});
it('sets correct UserLink props', () => {
expect(findUserLink().props()).toMatchObject({
user: mockUser,
userSignedIn: true,
});
});
});
describe('when sign in page emits `error` event', () => {
beforeEach(async () => {
createComponent({
provide: {
usersPath: '/mock',
subscriptions: [],
},
});
findSignInPage().vm.$emit('error');
await nextTick();
});
it('displays alert', () => {
const alert = findAlert();
expect(alert.exists()).toBe(true);
expect(alert.html()).toContain(I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE);
expect(alert.props('variant')).toBe('danger');
});
});
});
});
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { getGitlabSignInURL } from '~/jira_connect/subscriptions/utils';
import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue';
import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue';
import waitForPromises from 'helpers/wait_for_promises';
const MOCK_USERS_PATH = '/user';
jest.mock('~/jira_connect/subscriptions/utils');
describe('SignInButton', () => {
describe('SignInLegacyButton', () => {
let wrapper;
const createComponent = ({ slots } = {}) => {
wrapper = shallowMount(SignInButton, {
wrapper = shallowMount(SignInLegacyButton, {
propsData: {
usersPath: MOCK_USERS_PATH,
},
......@@ -30,7 +30,7 @@ describe('SignInButton', () => {
createComponent();
expect(findButton().exists()).toBe(true);
expect(findButton().text()).toBe(SignInButton.i18n.defaultButtonText);
expect(findButton().text()).toBe(SignInLegacyButton.i18n.defaultButtonText);
});
describe.each`
......
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import {
I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
OAUTH_WINDOW_OPTIONS,
} from '~/jira_connect/subscriptions/constants';
import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatus from '~/lib/utils/http_status';
import AccessorUtilities from '~/lib/utils/accessor';
jest.mock('~/lib/utils/accessor');
jest.mock('~/jira_connect/subscriptions/utils');
jest.mock('~/jira_connect/subscriptions/pkce', () => ({
createCodeVerifier: jest.fn().mockReturnValue('mock-verifier'),
createCodeChallenge: jest.fn().mockResolvedValue('mock-challenge'),
}));
const mockOauthMetadata = {
oauth_authorize_url: 'https://gitlab.com/mockOauth',
oauth_token_url: 'https://gitlab.com/mockOauthToken',
state: 'good-state',
};
describe('SignInOauthButton', () => {
let wrapper;
let mockAxios;
const createComponent = ({ slots } = {}) => {
wrapper = shallowMount(SignInOauthButton, {
slots,
provide: {
oauthMetadata: mockOauthMetadata,
},
});
};
beforeEach(() => {
mockAxios = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mockAxios.restore();
});
const findButton = () => wrapper.findComponent(GlButton);
it('displays a button', () => {
createComponent();
expect(findButton().exists()).toBe(true);
expect(findButton().text()).toBe(I18N_DEFAULT_SIGN_IN_BUTTON_TEXT);
});
it.each`
scenario | cryptoAvailable
${'when crypto API is available'} | ${true}
${'when crypto API is unavailable'} | ${false}
`('$scenario when canUseCrypto returns $cryptoAvailable', ({ cryptoAvailable }) => {
AccessorUtilities.canUseCrypto = jest.fn().mockReturnValue(cryptoAvailable);
createComponent();
expect(findButton().props('disabled')).toBe(!cryptoAvailable);
});
describe('on click', () => {
beforeEach(async () => {
jest.spyOn(window, 'open').mockReturnValue();
createComponent();
findButton().vm.$emit('click');
await nextTick();
});
it('sets `loading` prop of button to `true`', () => {
expect(findButton().props('loading')).toBe(true);
});
it('calls `window.open` with correct arguments', () => {
expect(window.open).toHaveBeenCalledWith(
`${mockOauthMetadata.oauth_authorize_url}?code_challenge=mock-challenge&code_challenge_method=S256`,
I18N_DEFAULT_SIGN_IN_BUTTON_TEXT,
OAUTH_WINDOW_OPTIONS,
);
});
it('sets the `codeVerifier` internal state', () => {
expect(wrapper.vm.codeVerifier).toBe('mock-verifier');
});
describe('on window message event', () => {
describe('when window message properties are corrupted', () => {
describe.each`
origin | state | messageOrigin | messageState
${window.origin} | ${mockOauthMetadata.state} | ${'bad-origin'} | ${mockOauthMetadata.state}
${window.origin} | ${mockOauthMetadata.state} | ${window.origin} | ${'bad-state'}
`(
'when message is [state=$messageState, origin=$messageOrigin]',
({ messageOrigin, messageState }) => {
beforeEach(async () => {
const mockEvent = {
origin: messageOrigin,
data: {
state: messageState,
code: '1234',
},
};
window.dispatchEvent(new MessageEvent('message', mockEvent));
await waitForPromises();
});
it('emits `error` event', () => {
expect(wrapper.emitted('error')).toBeTruthy();
});
it('does not emit `sign-in` event', () => {
expect(wrapper.emitted('sign-in')).toBeFalsy();
});
it('sets `loading` prop of button to `false`', () => {
expect(findButton().props('loading')).toBe(false);
});
},
);
});
describe('when window message properties are valid', () => {
const mockAccessToken = '5678';
const mockUser = { name: 'test user' };
const mockEvent = {
origin: window.origin,
data: {
state: mockOauthMetadata.state,
code: '1234',
},
};
describe('when API requests succeed', () => {
beforeEach(async () => {
jest.spyOn(axios, 'post');
jest.spyOn(axios, 'get');
mockAxios
.onPost(mockOauthMetadata.oauth_token_url)
.replyOnce(httpStatus.OK, { access_token: mockAccessToken });
mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.OK, mockUser);
window.dispatchEvent(new MessageEvent('message', mockEvent));
await waitForPromises();
});
it('executes POST request to Oauth token endpoint', () => {
expect(axios.post).toHaveBeenCalledWith(mockOauthMetadata.oauth_token_url, {
code: '1234',
code_verifier: 'mock-verifier',
});
});
it('executes GET request to fetch user data', () => {
expect(axios.get).toHaveBeenCalledWith('/api/v4/user', {
headers: { Authorization: `Bearer ${mockAccessToken}` },
});
});
it('emits `sign-in` event with user data', () => {
expect(wrapper.emitted('sign-in')[0]).toEqual([mockUser]);
});
});
describe('when API requests fail', () => {
beforeEach(async () => {
jest.spyOn(axios, 'post');
jest.spyOn(axios, 'get');
mockAxios
.onPost(mockOauthMetadata.oauth_token_url)
.replyOnce(httpStatus.INTERNAL_SERVER_ERROR, { access_token: mockAccessToken });
mockAxios.onGet('/api/v4/user').replyOnce(httpStatus.INTERNAL_SERVER_ERROR, mockUser);
window.dispatchEvent(new MessageEvent('message', mockEvent));
await waitForPromises();
});
it('emits `error` event', () => {
expect(wrapper.emitted('error')).toBeTruthy();
});
it('does not emit `sign-in` event', () => {
expect(wrapper.emitted('sign-in')).toBeFalsy();
});
it('sets `loading` prop of button to `false`', () => {
expect(findButton().props('loading')).toBe(false);
});
});
});
});
});
});
......@@ -7,7 +7,7 @@ jest.mock('~/jira_connect/subscriptions/utils', () => ({
getGitlabSignInURL: jest.fn().mockImplementation((path) => Promise.resolve(path)),
}));
describe('SubscriptionsList', () => {
describe('UserLink', () => {
let wrapper;
const createComponent = (propsData = {}, { provide } = {}) => {
......@@ -68,24 +68,35 @@ describe('SubscriptionsList', () => {
});
describe('gitlab user link', () => {
window.gon = { current_username: 'root' };
describe.each`
current_username | gitlabUserPath | user | expectedUserHandle | expectedUserLink
${'root'} | ${'/root'} | ${{ username: 'test-user' }} | ${'@root'} | ${'/root'}
${'root'} | ${'/root'} | ${undefined} | ${'@root'} | ${'/root'}
${undefined} | ${undefined} | ${{ username: 'test-user' }} | ${'@test-user'} | ${'/test-user'}
`(
'when current_username=$current_username, gitlabUserPath=$gitlabUserPath and user=$user',
({ current_username, gitlabUserPath, user, expectedUserHandle, expectedUserLink }) => {
beforeEach(() => {
window.gon = { current_username, relative_root_url: '' };
beforeEach(() => {
createComponent(
{
userSignedIn: true,
hasSubscriptions: true,
},
{ provide: { gitlabUserPath: '/root' } },
);
});
createComponent(
{
userSignedIn: true,
hasSubscriptions: true,
user,
},
{ provide: { gitlabUserPath } },
);
});
it('renders with correct href', () => {
expect(findGitlabUserLink().attributes('href')).toBe('/root');
});
it(`sets href to ${expectedUserLink}`, () => {
expect(findGitlabUserLink().attributes('href')).toBe(expectedUserLink);
});
it('contains GitLab user handle', () => {
expect(findGitlabUserLink().text()).toBe('@root');
});
it(`renders ${expectedUserHandle} as text`, () => {
expect(findGitlabUserLink().text()).toBe(expectedUserHandle);
});
},
);
});
});
import { mount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue';
import SignInButton from '~/jira_connect/subscriptions/components/sign_in_button.vue';
import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_legacy_button.vue';
import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
import createStore from '~/jira_connect/subscriptions/store';
import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '../../../../../app/assets/javascripts/jira_connect/subscriptions/constants';
jest.mock('~/jira_connect/subscriptions/utils');
const mockUsersPath = '/test';
const defaultProvide = {
oauthMetadata: {},
usersPath: mockUsersPath,
};
describe('SignInPage', () => {
let wrapper;
let store;
const findSignInButton = () => wrapper.findComponent(SignInButton);
const findSignInLegacyButton = () => wrapper.findComponent(SignInLegacyButton);
const findSignInOauthButton = () => wrapper.findComponent(SignInOauthButton);
const findSubscriptionsList = () => wrapper.findComponent(SubscriptionsList);
const createComponent = ({ provide, props } = {}) => {
const createComponent = ({ props, jiraConnectOauthEnabled } = {}) => {
store = createStore();
wrapper = mount(SignInPage, {
wrapper = shallowMount(SignInPage, {
store,
provide,
provide: {
...defaultProvide,
glFeatures: {
jiraConnectOauth: jiraConnectOauthEnabled,
},
},
propsData: props,
stubs: {
SignInLegacyButton,
SignInOauthButton,
},
});
};
......@@ -29,33 +47,74 @@ describe('SignInPage', () => {
});
describe('template', () => {
const mockUsersPath = '/test';
describe.each`
scenario | expectSubscriptionsList | signInButtonText
${'with subscriptions'} | ${true} | ${SignInPage.i18n.signinButtonTextWithSubscriptions}
${'without subscriptions'} | ${false} | ${SignInButton.i18n.defaultButtonText}
`('$scenario', ({ expectSubscriptionsList, signInButtonText }) => {
beforeEach(() => {
createComponent({
provide: {
usersPath: mockUsersPath,
},
props: {
hasSubscriptions: expectSubscriptionsList,
},
scenario | hasSubscriptions | signInButtonText
${'with subscriptions'} | ${true} | ${SignInPage.i18n.signInButtonTextWithSubscriptions}
${'without subscriptions'} | ${false} | ${I18N_DEFAULT_SIGN_IN_BUTTON_TEXT}
`('$scenario', ({ hasSubscriptions, signInButtonText }) => {
describe('when `jiraConnectOauthEnabled` feature flag is disabled', () => {
beforeEach(() => {
createComponent({
jiraConnectOauthEnabled: false,
props: {
hasSubscriptions,
},
});
});
});
it(`renders sign in button with text ${signInButtonText}`, () => {
expect(findSignInButton().text()).toMatchInterpolatedText(signInButtonText);
it('renders legacy sign in button', () => {
const button = findSignInLegacyButton();
expect(button.props('usersPath')).toBe(mockUsersPath);
expect(button.text()).toMatchInterpolatedText(signInButtonText);
});
});
it('renders sign in button with `usersPath` prop', () => {
expect(findSignInButton().props('usersPath')).toBe(mockUsersPath);
describe('when `jiraConnectOauthEnabled` feature flag is enabled', () => {
beforeEach(() => {
createComponent({
jiraConnectOauthEnabled: true,
props: {
hasSubscriptions,
},
});
});
describe('oauth sign in button', () => {
it('renders oauth sign in button', () => {
const button = findSignInOauthButton();
expect(button.text()).toMatchInterpolatedText(signInButtonText);
});
describe('when button emits `sign-in` event', () => {
it('emits `sign-in-oauth` event', () => {
const button = findSignInOauthButton();
const mockUser = { name: 'test' };
button.vm.$emit('sign-in', mockUser);
expect(wrapper.emitted('sign-in-oauth')[0]).toEqual([mockUser]);
});
});
describe('when button emits `error` event', () => {
it('emits `error` event', () => {
const button = findSignInOauthButton();
button.vm.$emit('error');
expect(wrapper.emitted('error')).toBeTruthy();
});
});
});
});
it(`${expectSubscriptionsList ? 'renders' : 'does not render'} subscriptions list`, () => {
expect(findSubscriptionsList().exists()).toBe(expectSubscriptionsList);
it(`${hasSubscriptions ? 'renders' : 'does not render'} subscriptions list`, () => {
createComponent({
props: {
hasSubscriptions,
},
});
expect(findSubscriptionsList().exists()).toBe(hasSubscriptions);
});
});
});
......
import crypto from 'crypto';
import { TextEncoder, TextDecoder } from 'util';
import { createCodeVerifier, createCodeChallenge } from '~/jira_connect/subscriptions/pkce';
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
describe('pkce', () => {
beforeAll(() => {
Object.defineProperty(global.self, 'crypto', {
value: {
getRandomValues: (arr) => crypto.randomBytes(arr.length),
subtle: {
digest: jest.fn().mockResolvedValue(new ArrayBuffer(1)),
},
},
});
});
describe('createCodeVerifier', () => {
it('calls `window.crypto.getRandomValues`', () => {
window.crypto.getRandomValues = jest.fn();
createCodeVerifier();
expect(window.crypto.getRandomValues).toHaveBeenCalled();
});
it(`returns a string with 128 characters`, () => {
const codeVerifier = createCodeVerifier();
expect(codeVerifier).toHaveLength(128);
});
});
describe('createCodeChallenge', () => {
it('calls `window.crypto.subtle.digest` with correct arguments', async () => {
await createCodeChallenge('1234');
expect(window.crypto.subtle.digest).toHaveBeenCalledWith('SHA-256', expect.anything());
});
it('returns base64 URL-encoded string', async () => {
const codeChallenge = await createCodeChallenge('1234');
expect(codeChallenge).toBe('AA');
});
});
});
......@@ -7,6 +7,11 @@ RSpec.describe JiraConnectHelper do
let_it_be(:subscription) { create(:jira_connect_subscription) }
let(:user) { create(:user) }
let(:client_id) { '123' }
before do
stub_env('JIRA_CONNECT_OAUTH_CLIENT_ID', client_id)
end
subject { helper.jira_connect_app_data([subscription]) }
......@@ -29,6 +34,47 @@ RSpec.describe JiraConnectHelper do
expect(subject[:users_path]).to eq(jira_connect_users_path)
end
context 'with oauth_metadata' do
let(:oauth_metadata) { helper.jira_connect_app_data([subscription])[:oauth_metadata] }
subject(:parsed_oauth_metadata) { Gitlab::Json.parse(oauth_metadata).deep_symbolize_keys }
it 'assigns oauth_metadata' do
expect(parsed_oauth_metadata).to include(
oauth_authorize_url: start_with('http://test.host/oauth/authorize?'),
oauth_token_url: 'http://test.host/oauth/token',
state: %r/[a-z0-9.]{32}/,
oauth_token_payload: hash_including(
grant_type: 'authorization_code',
client_id: client_id,
redirect_uri: 'http://test.host/-/jira_connect/oauth_callbacks'
)
)
end
it 'includes oauth_authorize_url with all params' do
params = Rack::Utils.parse_nested_query(URI.parse(parsed_oauth_metadata[:oauth_authorize_url]).query)
expect(params).to include(
'client_id' => client_id,
'response_type' => 'code',
'scope' => 'api',
'redirect_uri' => 'http://test.host/-/jira_connect/oauth_callbacks',
'state' => parsed_oauth_metadata[:state]
)
end
context 'jira_connect_oauth feature is disabled' do
before do
stub_feature_flags(jira_connect_oauth: false)
end
it 'does not assign oauth_metadata' do
expect(oauth_metadata).to be_nil
end
end
end
it 'passes group as "skip_groups" param' do
skip_groups_param = CGI.escape('skip_groups[]')
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe JiraConnect::OauthCallbacksController do
describe 'GET /-/jira_connect/oauth_callbacks' do
context 'when logged in' do
let_it_be(:user) { create(:user) }
before do
sign_in(user)
end
it 'renders a page prompting the user to close the window' do
get '/-/jira_connect/oauth_callbacks'
expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to include('You can close this window.')
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment