Commit 20dbaecb authored by Tom Quirk's avatar Tom Quirk Committed by Luke Duncalfe

Add backend OAuth setup for Jira connect

This adds the backend parts to enable an OAuth
flow for the GitLab.com for Jira App. It provides
the data for the frontend to initialize the OAuth flow
and retrieve the token after successful authorization.
parent ed28668b
......@@ -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