Skip to content

Commit 046029b

Browse files
Adds support for link to account login (#3390)
* Adds /login link and handler * Uses options map to pass options rather than scopes * Fixes error message * Updates params and messaging * Accept context param * Updates wording
1 parent 41de0b1 commit 046029b

File tree

4 files changed

+110
-27
lines changed

4 files changed

+110
-27
lines changed

src/plus/gk/account/authenticationConnection.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getLogScope } from '../../../system/logger.scope';
1111
import { openUrl } from '../../../system/utils';
1212
import type { ServerConnection } from '../serverConnection';
1313

14+
export const LoginUriPathPrefix = 'login';
1415
export const AuthenticationUriPathPrefix = 'did-authenticate';
1516

1617
interface AccountInfo {
@@ -113,7 +114,7 @@ export class AuthenticationConnection implements Disposable {
113114
new Promise<string>((_, reject) => setTimeout(reject, 120000, 'Cancelled')),
114115
]);
115116

116-
const token = await this.getTokenFromCodeAndState(scopeKey, code, gkstate);
117+
const token = await this.getTokenFromCodeAndState(code, gkstate, scopeKey);
117118
return token;
118119
} finally {
119120
this._cancellationSource?.cancel();
@@ -167,10 +168,12 @@ export class AuthenticationConnection implements Disposable {
167168
}
168169
}
169170

170-
private async getTokenFromCodeAndState(scopeKey: string, code: string, state: string): Promise<string> {
171-
const existingStates = this._pendingStates.get(scopeKey);
172-
if (!existingStates?.includes(state)) {
173-
throw new Error('Getting token failed: Invalid state');
171+
async getTokenFromCodeAndState(code: string, state?: string, scopeKey?: string): Promise<string> {
172+
if (state != null && scopeKey != null) {
173+
const existingStates = this._pendingStates.get(scopeKey);
174+
if (!existingStates?.includes(state)) {
175+
throw new Error('Getting token failed: Invalid state');
176+
}
174177
}
175178

176179
const rsp = await this.connection.fetchGkDevApi(
@@ -181,7 +184,7 @@ export class AuthenticationConnection implements Disposable {
181184
grant_type: 'authorization_code',
182185
client_id: 'gitkraken.gitlens',
183186
code: code,
184-
state: state,
187+
state: state ?? '',
185188
}),
186189
},
187190
{

src/plus/gk/account/authenticationProvider.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export const authenticationProviderId = 'gitlens+';
2828
export const authenticationProviderScopes = ['gitlens'];
2929
const authenticationLabel = 'GitKraken: GitLens';
3030

31+
export interface AuthenticationProviderOptions {
32+
signUp?: boolean;
33+
signIn?: { code: string; state?: string };
34+
}
35+
3136
export class AccountAuthenticationProvider implements AuthenticationProvider, Disposable {
3237
private _onDidChangeSessions = new EventEmitter<AuthenticationProviderAuthenticationSessionsChangeEvent>();
3338
get onDidChangeSessions() {
@@ -37,6 +42,7 @@ export class AccountAuthenticationProvider implements AuthenticationProvider, Di
3742
private readonly _disposable: Disposable;
3843
private readonly _authConnection: AuthenticationConnection;
3944
private _sessionsPromise: Promise<AuthenticationSession[]>;
45+
private _optionsByScope: Map<string, AuthenticationProviderOptions> | undefined;
4046

4147
constructor(
4248
private readonly container: Container,
@@ -68,22 +74,32 @@ export class AccountAuthenticationProvider implements AuthenticationProvider, Di
6874
return this._authConnection.abort();
6975
}
7076

77+
public setOptionsForScopes(scopes: string[], options: AuthenticationProviderOptions) {
78+
this._optionsByScope ??= new Map<string, AuthenticationProviderOptions>();
79+
this._optionsByScope.set(getScopesKey(scopes), options);
80+
}
81+
82+
public clearOptionsForScopes(scopes: string[]) {
83+
this._optionsByScope?.delete(getScopesKey(scopes));
84+
}
85+
7186
@debug()
7287
public async createSession(scopes: string[]): Promise<AuthenticationSession> {
7388
const scope = getLogScope();
7489

75-
const signUp = scopes.includes('signUp');
76-
// 'signUp' is just a flag, not a valid scope, so remove it before continuing
77-
if (signUp) {
78-
scopes = scopes.filter(s => s !== 'signUp');
90+
const options = this._optionsByScope?.get(getScopesKey(scopes));
91+
if (options != null) {
92+
this._optionsByScope?.delete(getScopesKey(scopes));
7993
}
80-
8194
// Ensure that the scopes are sorted consistently (since we use them for matching and order doesn't matter)
8295
scopes = scopes.sort();
8396
const scopesKey = getScopesKey(scopes);
8497

8598
try {
86-
const token = await this._authConnection.login(scopes, scopesKey, signUp);
99+
const token =
100+
options?.signIn != null
101+
? await this._authConnection.getTokenFromCodeAndState(options.signIn.code, options.signIn.state)
102+
: await this._authConnection.login(scopes, scopesKey, options?.signUp);
87103
const session = await this.createSessionForToken(token, scopes);
88104

89105
const sessions = await this._sessionsPromise;

src/plus/gk/account/subscriptionService.ts

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
Event,
66
MessageItem,
77
StatusBarItem,
8+
Uri,
89
} from 'vscode';
910
import {
1011
authentication,
@@ -106,6 +107,7 @@ export class SubscriptionService implements Disposable {
106107
}
107108
}),
108109
container.uri.onDidReceiveSubscriptionUpdatedUri(this.onSubscriptionUpdatedUri, this),
110+
container.uri.onDidReceiveLoginUri(this.onLoginUri, this),
109111
);
110112

111113
const subscription = this.getStoredSubscription();
@@ -359,14 +361,39 @@ export class SubscriptionService implements Disposable {
359361
);
360362
}
361363

364+
return this.loginCore({ signUp: signUp, source: source });
365+
}
366+
367+
async loginWithCode(authentication: { code: string; state?: string }, source?: Source): Promise<boolean> {
368+
if (!(await ensurePlusFeaturesEnabled())) return false;
369+
if (this.container.telemetry.enabled) {
370+
this.container.telemetry.sendEvent('subscription/action', { action: 'sign-in' }, source);
371+
}
372+
373+
const session = await this.ensureSession(false);
374+
if (session != null) {
375+
await this.logout(undefined, source);
376+
}
377+
378+
return this.loginCore({ signIn: authentication, source: source });
379+
}
380+
381+
private async loginCore(options?: {
382+
signUp?: boolean;
383+
source?: Source;
384+
signIn?: { code: string; state?: string };
385+
}): Promise<boolean> {
362386
// Abort any waiting authentication to ensure we can start a new flow
363387
await this.container.accountAuthentication.abort();
364388
void this.showAccountView();
365389

366-
const session = await this.ensureSession(true, { signUp: signUp });
390+
const session = await this.ensureSession(true, {
391+
signIn: options?.signIn,
392+
signUp: options?.signUp,
393+
});
367394
const loggedIn = Boolean(session);
368395
if (loggedIn) {
369-
void this.showPlanMessage(source);
396+
void this.showPlanMessage(options?.source);
370397
}
371398
return loggedIn;
372399
}
@@ -914,7 +941,7 @@ export class SubscriptionService implements Disposable {
914941
@debug()
915942
private async ensureSession(
916943
createIfNeeded: boolean,
917-
options?: { force?: boolean; signUp?: boolean },
944+
options?: { force?: boolean; signUp?: boolean; signIn?: { code: string; state?: string } },
918945
): Promise<AuthenticationSession | undefined> {
919946
if (this._sessionPromise != null) {
920947
void (await this._sessionPromise);
@@ -924,7 +951,10 @@ export class SubscriptionService implements Disposable {
924951
if (this._session === null && !createIfNeeded) return undefined;
925952

926953
if (this._sessionPromise === undefined) {
927-
this._sessionPromise = this.getOrCreateSession(createIfNeeded, options?.signUp).then(
954+
this._sessionPromise = this.getOrCreateSession(createIfNeeded, {
955+
signUp: options?.signUp,
956+
signIn: options?.signIn,
957+
}).then(
928958
s => {
929959
this._session = s;
930960
this._sessionPromise = undefined;
@@ -945,23 +975,24 @@ export class SubscriptionService implements Disposable {
945975
@debug()
946976
private async getOrCreateSession(
947977
createIfNeeded: boolean,
948-
signUp: boolean = false,
978+
options?: { signUp?: boolean; signIn?: { code: string; state?: string } },
949979
): Promise<AuthenticationSession | null> {
950980
const scope = getLogScope();
951981

952982
let session: AuthenticationSession | null | undefined;
953-
954983
try {
955-
session = await authentication.getSession(
956-
authenticationProviderId,
957-
signUp ? [...authenticationProviderScopes, 'signUp'] : authenticationProviderScopes,
958-
{
959-
createIfNone: createIfNeeded,
960-
silent: !createIfNeeded,
961-
},
962-
);
984+
if (options != null && createIfNeeded) {
985+
this.container.accountAuthentication.setOptionsForScopes(authenticationProviderScopes, options);
986+
}
987+
session = await authentication.getSession(authenticationProviderId, authenticationProviderScopes, {
988+
createIfNone: createIfNeeded,
989+
silent: !createIfNeeded,
990+
});
963991
} catch (ex) {
964992
session = null;
993+
if (options != null && createIfNeeded) {
994+
this.container.accountAuthentication.clearOptionsForScopes(authenticationProviderScopes);
995+
}
965996

966997
if (ex instanceof Error && ex.message.includes('User did not consent')) {
967998
setLogScopeExit(scope, ' \u2022 User declined authentication');
@@ -1349,6 +1380,31 @@ export class SubscriptionService implements Disposable {
13491380
);
13501381
}
13511382

1383+
onLoginUri(uri: Uri) {
1384+
const scope = getLogScope();
1385+
const queryParams: URLSearchParams = new URLSearchParams(uri.query);
1386+
const code = queryParams.get('code');
1387+
const state = queryParams.get('state');
1388+
const context = queryParams.get('context');
1389+
let contextMessage = 'sign in to GitKraken';
1390+
1391+
switch (context) {
1392+
case 'start_trial':
1393+
contextMessage = 'start a Pro trial';
1394+
break;
1395+
}
1396+
1397+
if (code == null) {
1398+
Logger.error(`No code provided. Link: ${uri.toString(true)}`, scope);
1399+
void window.showErrorMessage(
1400+
`Unable to ${contextMessage} with that link. Please try clicking the link again. If this issue persists, please contact support.`,
1401+
);
1402+
return;
1403+
}
1404+
1405+
void this.loginWithCode({ code: code, state: state ?? undefined }, { source: 'deeplink' });
1406+
}
1407+
13521408
async onSubscriptionUpdatedUri() {
13531409
if (this._session == null) return;
13541410
const oldSubscriptionState = this._subscription.state;

src/uris/uriService.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Disposable, Event, Uri, UriHandler } from 'vscode';
22
import { EventEmitter, window } from 'vscode';
33
import type { Container } from '../container';
4-
import { AuthenticationUriPathPrefix } from '../plus/gk/account/authenticationConnection';
4+
import { AuthenticationUriPathPrefix, LoginUriPathPrefix } from '../plus/gk/account/authenticationConnection';
55
import { SubscriptionUpdatedUriPathPrefix } from '../plus/gk/account/subscription';
66
import { CloudIntegrationAuthenticationUriPathPrefix } from '../plus/integrations/authentication/models';
77
import { log } from '../system/decorators/log';
@@ -13,13 +13,18 @@ export class UriService implements Disposable, UriHandler {
1313
private _disposable: Disposable;
1414

1515
private _onDidReceiveAuthenticationUri: EventEmitter<Uri> = new EventEmitter<Uri>();
16+
private _onDidReceiveLoginUri: EventEmitter<Uri> = new EventEmitter<Uri>();
1617
private _onDidReceiveCloudIntegrationAuthenticationUri: EventEmitter<Uri> = new EventEmitter<Uri>();
1718
private _onDidReceiveSubscriptionUpdatedUri: EventEmitter<Uri> = new EventEmitter<Uri>();
1819

1920
get onDidReceiveAuthenticationUri(): Event<Uri> {
2021
return this._onDidReceiveAuthenticationUri.event;
2122
}
2223

24+
get onDidReceiveLoginUri(): Event<Uri> {
25+
return this._onDidReceiveLoginUri.event;
26+
}
27+
2328
get onDidReceiveCloudIntegrationAuthenticationUri(): Event<Uri> {
2429
return this._onDidReceiveCloudIntegrationAuthenticationUri.event;
2530
}
@@ -53,6 +58,9 @@ export class UriService implements Disposable, UriHandler {
5358
} else if (type === SubscriptionUpdatedUriPathPrefix) {
5459
this._onDidReceiveSubscriptionUpdatedUri.fire(uri);
5560
return;
61+
} else if (type === LoginUriPathPrefix) {
62+
this._onDidReceiveLoginUri.fire(uri);
63+
return;
5664
}
5765

5866
this._onDidReceiveUri.fire(uri);

0 commit comments

Comments
 (0)