Commit e2b59816 authored by Tomas Vik's avatar Tomas Vik
Browse files

feat: OAuth authentication to GitLab.com

Includes token refreshing.
parent 3ae8ee92
Pipeline #546842742 failed with stages
in 8 minutes and 55 seconds
......@@ -389,10 +389,6 @@
{
"command": "gl.clearSelectedProject",
"when": "false"
},
{
"command": "gl.authenticate",
"when": "false"
}
],
"view/title": [
......
......@@ -14,6 +14,8 @@ export interface TokenAccount extends AccountBase {
export interface OAuthAccount extends AccountBase {
type: 'oauth';
scopes: string[];
refreshToken: string;
expiresAtTimestampInSeconds: number;
}
export type Account = TokenAccount | OAuthAccount;
import { createTokenAccount } from '../test_utils/entities';
import { createOAuthAccount, createTokenAccount } from '../test_utils/entities';
import { SecretStorage } from '../test_utils/secret_storage';
import { Account } from './account';
import { Account, OAuthAccount } from './account';
import { AccountService } from './account_service';
const SECRETS_KEY = 'gitlab-tokens';
......@@ -144,15 +144,58 @@ describe('AccountService', () => {
expect(result).toEqual(firstAccount);
});
it('can update token', async () => {
const firstAccount = createTokenAccount('https://gitlab.com', 1, 'abc');
const updatedTokenAccount = { ...firstAccount, token: 'xyz' };
await accountService.addAccount(firstAccount);
describe('updateAccountSecret', () => {
it('can update Token Account', async () => {
const firstAccount = createTokenAccount('https://gitlab.com', 1, 'abc');
const updatedTokenAccount = { ...firstAccount, token: 'xyz' };
await accountService.addAccount(firstAccount);
await accountService.updateAccountToken(updatedTokenAccount);
await accountService.updateAccountSecret(updatedTokenAccount);
const result = accountService.getAccount(firstAccount.id);
const result = accountService.getAccount(firstAccount.id);
expect(result).toEqual(updatedTokenAccount);
});
it('can update OAuth Account', async () => {
const nowTimestamp = Math.floor(new Date().getTime() / 1000);
const account = createOAuthAccount();
const updatedOAuthAccount = {
...account,
token: 'xyz',
refreshToken: 'z12',
expiresAtTimestampInSeconds: nowTimestamp + 30,
};
await accountService.addAccount(account);
await accountService.updateAccountSecret(updatedOAuthAccount);
const result = accountService.getAccount(account.id);
expect(result).toEqual(updatedOAuthAccount);
});
});
describe('OAuth accounts', () => {
let account: OAuthAccount;
beforeEach(async () => {
account = createOAuthAccount();
await accountService.addAccount(account);
});
it('can store OAuth account', async () => {
const result = accountService.getAccount(account.id);
expect(result).toEqual(updatedTokenAccount);
expect(result).toEqual(account);
});
it('does not store secrets in global state', async () => {
expect(accountMap[account.id]).toEqual({
...account,
token: undefined,
refreshToken: undefined,
expiresAtTimestampInSeconds: undefined,
});
});
});
});
......@@ -5,14 +5,24 @@ import { hasPresentKey } from '../utils/has_present_key';
import { notNullOrUndefined } from '../utils/not_null_or_undefined';
import { removeTrailingSlash } from '../utils/remove_trailing_slash';
import { uniq } from '../utils/uniq';
import { Account, makeAccountId } from './account';
import { Account, makeAccountId, OAuthAccount, TokenAccount } from './account';
import { Credentials } from './credentials';
type AccountWithoutToken = Omit<Account, 'token'>;
interface Secret {
interface TokenSecret {
token: string;
}
interface OAuthSecret {
token: string;
refreshToken: string;
expiresAtTimestampInSeconds: number;
}
type Secret = TokenSecret | OAuthSecret;
type AccountWithoutSecret =
| Omit<TokenAccount, keyof TokenSecret>
| Omit<OAuthAccount, keyof OAuthSecret>;
const getEnvironmentVariables = (): Credentials | undefined => {
const { GITLAB_WORKFLOW_INSTANCE_URL, GITLAB_WORKFLOW_TOKEN } = process.env;
if (!GITLAB_WORKFLOW_INSTANCE_URL || !GITLAB_WORKFLOW_TOKEN) return undefined;
......@@ -43,6 +53,20 @@ const getSecrets = async (
return stringTokens ? JSON.parse(stringTokens) : {};
};
const splitAccount = (
account: Account,
): { accountWithoutSecret: AccountWithoutSecret; secret: Secret } => {
if (account.type === 'token') {
const { token, ...accountWithoutSecret } = account;
return { accountWithoutSecret, secret: { token } };
}
if (account.type === 'oauth') {
const { token, refreshToken, expiresAtTimestampInSeconds, ...accountWithoutSecret } = account;
return { accountWithoutSecret, secret: { token, refreshToken, expiresAtTimestampInSeconds } };
}
throw new Error(`Unexpected account type for account ${JSON.stringify(account)}`);
};
export class AccountService {
context?: ExtensionContext;
......@@ -59,7 +83,7 @@ export class AccountService {
return this.onDidChangeEmitter.event;
}
private get accountMap(): Record<string, AccountWithoutToken | undefined> {
private get accountMap(): Record<string, AccountWithoutSecret | undefined> {
assert(this.context);
return this.context.globalState.get(ACCOUNTS_KEY, {});
}
......@@ -99,12 +123,12 @@ export class AccountService {
);
return;
}
const { token, ...accountWithoutToken } = account;
await this.#storeToken(account.id, token);
const { secret, accountWithoutSecret } = splitAccount(account);
await this.#storeSecret(account.id, secret);
await this.context.globalState.update(ACCOUNTS_KEY, {
...accountMap,
[account.id]: accountWithoutToken,
[account.id]: accountWithoutSecret,
});
this.onDidChangeEmitter.fire();
......@@ -127,10 +151,10 @@ export class AccountService {
await this.context.secrets.store(SECRETS_KEY, JSON.stringify(this.secrets));
}
async #storeToken(accountId: string, token: string) {
async #storeSecret(accountId: string, secret: Secret) {
assert(this.context);
await this.#validateSecretsAreUpToDate();
const secrets = { ...this.secrets, [accountId]: { token } };
const secrets = { ...this.secrets, [accountId]: secret };
await this.context.secrets.store(SECRETS_KEY, JSON.stringify(secrets));
this.secrets = secrets;
}
......@@ -141,11 +165,11 @@ export class AccountService {
return result;
}
async updateAccountToken(account: Account) {
async updateAccountSecret(account: Account) {
assert(this.context);
const { token } = account;
await this.#storeToken(account.id, token);
const { secret } = splitAccount(account);
await this.#storeSecret(account.id, secret);
}
async removeAccount(accountId: string) {
......@@ -158,14 +182,15 @@ export class AccountService {
this.onDidChangeEmitter.fire();
}
getRemovableAccounts(): AccountWithoutToken[] {
getRemovableAccounts(): AccountWithoutSecret[] {
return Object.values(this.accountMap).filter(notNullOrUndefined);
}
#getRemovableAccountsWithTokens(): Account[] {
const accountsWithMaybeTokens = this.getRemovableAccounts().map(a => ({
...a,
token: this.secrets[a.id]?.token,
token: undefined,
...this.secrets[a.id],
}));
accountsWithMaybeTokens
.filter(a => !a.token)
......
......@@ -87,6 +87,9 @@ interface RestNote {
interface ExchangeTokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
created_at: number; // TODO: make sure this time changes when we refresh the token
}
export interface AuthorizationCodeTokenExchangeParams {
......@@ -96,8 +99,15 @@ export interface AuthorizationCodeTokenExchangeParams {
grantType: 'authorization_code';
}
interface RefreshTokenExchangeParams {
instanceUrl: string;
grantType: 'refresh_token';
refreshToken: string;
}
/** Parameters used to exchange code for token with the GitLab OAuth service */
export type TokenExchangeUrlParams = AuthorizationCodeTokenExchangeParams;
export type TokenExchangeUrlParams =
| AuthorizationCodeTokenExchangeParams
| RefreshTokenExchangeParams;
function isLabelEvent(note: Note): note is RestLabelEvent {
return (note as RestLabelEvent).label !== undefined;
......@@ -990,9 +1000,11 @@ export class GitLabService {
`client_id=${OAUTH_CLIENT_ID}`,
`redirect_uri=${OAUTH_REDIRECT_URI}`,
`grant_type=${params.grantType}`,
`code_verifier=${params.codeVerifier}`,
];
const grantTypeParams = [`code=${params.code}`];
const grantTypeParams =
params.grantType === 'authorization_code'
? [`code=${params.code}`, `code_verifier=${params.codeVerifier}`]
: [`refresh_token=${params.refreshToken}`];
const response = await crossFetch(`${params.instanceUrl}/oauth/token`, {
...fetchOptions,
method: 'POST',
......
......@@ -2,6 +2,7 @@ import { Account } from '../accounts/account';
import { AccountService } from '../accounts/account_service';
import { createExtensionContext, createOAuthAccount } from '../test_utils/entities';
import { RefreshingGitLabService } from './refreshing_gitlab_service';
import { TokenExchangeService } from './token_exchange_service';
describe('RefreshingGitLabService', () => {
let accountService: AccountService;
......@@ -14,7 +15,7 @@ describe('RefreshingGitLabService', () => {
account = createOAuthAccount();
await accountService.addAccount(account);
service = new RefreshingGitLabService(account, accountService);
service = new RefreshingGitLabService(account, new TokenExchangeService(accountService));
});
it('uses account from AccountService', async () => {
......@@ -23,7 +24,7 @@ describe('RefreshingGitLabService', () => {
it('loads the latest account from account service', async () => {
const updatedAccount = { ...account, token: 'xyz' };
await accountService.updateAccountToken(updatedAccount);
await accountService.updateAccountSecret(updatedAccount);
expect(await service.getCredentials()).toEqual(updatedAccount);
});
......
import { Account } from '../accounts/account';
import { AccountService, accountService } from '../accounts/account_service';
import { Credentials } from '../accounts/credentials';
import { GitLabService } from './gitlab_service';
import { TokenExchangeService, tokenExchangeService } from './token_exchange_service';
export class RefreshingGitLabService extends GitLabService {
#accountId: string;
#accountService: AccountService;
#tokenExchangeService: TokenExchangeService;
constructor(account: Account, as = accountService) {
constructor(account: Account, tes = tokenExchangeService) {
super({ instanceUrl: account.instanceUrl, token: account.token });
this.#accountId = account.id;
this.#accountService = as;
this.#tokenExchangeService = tes;
}
async getCredentials(): Promise<Credentials> {
return this.#accountService.getAccount(this.#accountId);
return this.#tokenExchangeService.refreshIfNeeded(this.#accountId);
}
}
import { AccountService } from '../accounts/account_service';
import { asMock } from '../test_utils/as_mock';
import {
createExtensionContext,
createOAuthAccount,
createTokenAccount,
} from '../test_utils/entities';
import { GitLabService } from './gitlab_service';
import { TokenExchangeService } from './token_exchange_service';
jest.mock('./gitlab_service');
const unixTimestampNow = () => Math.floor(new Date().getTime() / 1000);
describe('TokenExchangeService', () => {
describe('refreshing token', () => {
let accountService: AccountService;
let tokenExchangeService: TokenExchangeService;
beforeEach(async () => {
accountService = new AccountService();
await accountService.init(createExtensionContext());
tokenExchangeService = new TokenExchangeService(accountService);
});
it('returns unchanged TokenAccount', async () => {
const tokenAccount = createTokenAccount();
await accountService.addAccount(tokenAccount);
const result = await tokenExchangeService.refreshIfNeeded(tokenAccount.id);
expect(result).toEqual(tokenAccount);
});
it('returns valid OAuth account without change', async () => {
const oauthAccount = createOAuthAccount();
await accountService.addAccount(oauthAccount);
const result = await tokenExchangeService.refreshIfNeeded(oauthAccount.id);
expect(result).toEqual(oauthAccount);
});
it('refreshes expired OAuth account', async () => {
const timestampNow = unixTimestampNow();
const tokenExpiresIn = 7200;
const expiredAccount = {
...createOAuthAccount(),
refreshToken: 'def',
codeVerifier: 'abc',
expiresAtTimestampInSeconds: timestampNow - 60, // expired 60s ago
};
await accountService.addAccount(expiredAccount);
// mock API token refresh response
asMock(GitLabService.exchangeToken).mockResolvedValue({
access_token: 'new_token',
refresh_token: 'new_refresh_token',
expires_in: tokenExpiresIn,
created_at: timestampNow,
});
const result = await tokenExchangeService.refreshIfNeeded(expiredAccount.id);
// account has been refreshed
expect(result).toEqual({
...expiredAccount,
refreshToken: 'new_refresh_token',
token: 'new_token',
expiresAtTimestampInSeconds: timestampNow + tokenExpiresIn,
});
// verify that we called API with correct parameters
const { refreshToken, instanceUrl } = expiredAccount;
expect(GitLabService.exchangeToken).toHaveBeenCalledWith({
grantType: 'refresh_token',
refreshToken,
instanceUrl,
});
});
});
});
import { makeAccountId, OAuthAccount } from '../accounts/account';
import assert from 'assert';
import { Account, makeAccountId, OAuthAccount } from '../accounts/account';
import { accountService, AccountService } from '../accounts/account_service';
import { GITLAB_COM_URL } from '../constants';
import { log } from '../log';
import { AuthorizationCodeTokenExchangeParams, GitLabService } from './gitlab_service';
export const needsRefresh = (account: Account) => {
if (account.type === 'token') return false;
const unixTimestampNow = Math.floor(new Date().getTime() / 1000);
return account.expiresAtTimestampInSeconds - 7170 <= unixTimestampNow; // subtract 7170 from expiresAtTimestampInSeconds to simulate expiration every 30s
};
export class TokenExchangeService {
#accountService: AccountService;
#refreshesInProgress: Record<string, Promise<Account> | undefined> = {};
constructor(as = accountService) {
this.#accountService = as;
}
......@@ -27,6 +37,8 @@ export class TokenExchangeService {
const account: OAuthAccount = {
instanceUrl: GITLAB_COM_URL,
token: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
expiresAtTimestampInSeconds: tokenResponse.created_at + tokenResponse.expires_in,
id: makeAccountId(GITLAB_COM_URL, user.id),
type: 'oauth',
username: user.username,
......@@ -35,6 +47,41 @@ export class TokenExchangeService {
await this.#accountService.addAccount(account);
return account;
}
async refreshIfNeeded(accountId: string): Promise<Account> {
const latestAccount = this.#accountService.getAccount(accountId);
if (!needsRefresh(latestAccount)) {
log.info(`Using non-expired account ${JSON.stringify(latestAccount)}`);
return latestAccount;
}
const refreshInProgress = this.#refreshesInProgress[accountId];
if (refreshInProgress) return refreshInProgress;
assert(latestAccount.type === 'oauth');
log.info(`Refreshing expired account ${JSON.stringify(latestAccount)}.`);
const refresh = this.#refreshToken(latestAccount).finally(() => {
delete this.#refreshesInProgress[accountId];
});
this.#refreshesInProgress[accountId] = refresh;
return refresh;
}
async #refreshToken(account: OAuthAccount): Promise<OAuthAccount> {
const { instanceUrl, refreshToken } = account;
const response = await GitLabService.exchangeToken({
grantType: 'refresh_token',
instanceUrl,
refreshToken,
});
const refreshedAccount: OAuthAccount = {
...account,
token: response.access_token,
refreshToken: response.refresh_token,
expiresAtTimestampInSeconds: response.created_at + response.expires_in, // FIXME: this logic is duplicated in the gitlab_authentication_provider
};
await this.#accountService.updateAccountSecret(refreshedAccount);
log.info(`Saved refreshed account ${JSON.stringify(refreshedAccount)}.`);
return refreshedAccount;
}
}
export const tokenExchangeService = new TokenExchangeService();
......@@ -148,6 +148,8 @@ export const createOAuthAccount = (
token,
type: 'oauth',
scopes: ['read_user', 'api'],
refreshToken: 'def',
expiresAtTimestampInSeconds: Math.floor(new Date().getTime() / 1000) + 1000, // valid token
});
export const gitRepository = {
......
Supports Markdown
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