Skip to content

Refine OAuth scopes for GitLab integration [ON HOLD] #3164

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon
}
}
}

protected async redirectToLogin() {
window.location.href = new GitpodHostUrl(window.location.toString()).with({
pathname: '/login/',
Expand Down Expand Up @@ -127,7 +127,7 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon
return undefined;
}
const scopes = authProvider.scopes || [];
const updatedScopes = updatedScopesString.split(',').filter(s => !!s && scopes.some(scope => scope === s));
const updatedScopes = updatedScopesString.split(',').filter(s => !!s && scopes.all.some(scope => scope === s));
if (updatedScopes.length > 0 && hosts.some(h => h === updatedHost)) {
return { updatedHost, updatedScopes };
}
Expand All @@ -154,7 +154,7 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon
const authProvider = authProviders.find(p => p.host === host)!;
let newScopes = new Set<string>(value.split(','));
let oldScopes = new Set<string>(scopes.get(host) || []);
const merged = new Set<string>((authProvider.scopes || []).filter(scope => oldScopes.has(scope) || newScopes.has(scope)));
const merged = new Set<string>((authProvider.scopes.all || []).filter(scope => oldScopes.has(scope) || newScopes.has(scope)));
scopes.set(host, merged);
}
});
Expand Down Expand Up @@ -289,12 +289,11 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon
this.setState({ newScopes });
}

protected renderScopeTooltip(scope: string) {
const text = this.getTooltipForScope(scope);
if (!text) {
protected renderScopeTooltip(description: string | undefined) {
if (!description) {
return undefined;
}
return (<Tooltip title={text} placement="right" interactive style={{ maxWidth: 200, padding: '12px' }}>
return (<Tooltip title={description} placement="right" interactive style={{ maxWidth: 200, padding: '12px' }}>
<span>
<InfoIcon fontSize="small" color="disabled" style={{ verticalAlign: 'middle' }} />
</span>
Expand Down Expand Up @@ -355,9 +354,9 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon

<Grid item style={{ flexGrow: 2 }}>
<CardContent style={{ display: 'block', float: 'left', textAlign: 'left', paddingBottom: '8px' }}>
{(provider.scopes || []).map((scope, index) => {
const disabled = provider.requirements && provider.requirements.default.includes(scope);
const defaultChecked = newScopes.has(scope) || (provider.requirements && provider.requirements.default.includes(scope));
{(provider.scopes.all || []).map((scope, index) => {
const disabled = provider.scopes.default.includes(scope);
const defaultChecked = newScopes.has(scope) || (provider.scopes.default.includes(scope));
const color = newScopes.has(scope) && !oldScopes.has(scope) ? 'secondary' : 'primary';
return (<p key={host + "-scope-" + index} style={{ display: 'table', marginTop: 0 }}>
<label style={{ display: 'flex', alignItems: 'center' }}>
Expand All @@ -368,7 +367,7 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon
defaultChecked={defaultChecked}
/>
{this.getLabelForScope(scope)}
{this.renderScopeTooltip(scope)}
{this.renderScopeTooltip(provider.scopes.descriptions[scope])}
</label>
</p>);
})}
Expand All @@ -391,7 +390,7 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon
this.renderUpdateButton(dirty, () => this.updateToken(provider.host, Array.from(newScopes)), 'Update') :
this.renderUpdateButton(true, () => this.updateToken(provider.host,
// for authorization we set the required (if any) plus the new scopes
[...(provider.requirements && provider.requirements.default || []), ...Array.from(newScopes)]), 'Connect'))
[...(provider.scopes.default || []), ...Array.from(newScopes)]), 'Connect'))
}
</CardActions>
</Grid>
Expand All @@ -414,11 +413,13 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon
case "read:org": return "read organizations";
case "workflow": return "update workflows";
// GitLab
case "read_user": return "read user";
case "api": return "allow api calls";
case "read_repository": return "repository access";
case "read_user": return "read user info";
case "api": return "full API access";
case "read_api": return "read API access";
case "read_repository": return "read repositories";
case "write_repository": return "write repository";
// Bitbucket
case "account": return "read account";
case "account": return "read account info";
case "repository": return "read repositories";
case "repository:write": return "write repositories";
case "pullrequest": return "read pull requests";
Expand All @@ -428,28 +429,6 @@ export class AccessControl extends React.Component<AccessControlProps, AccessCon
}
}

protected getTooltipForScope(scope: string): string | undefined {
switch (scope) {
case "user:email": return "Read-only access to your email addresses";
case "public_repo": return "Write access to code in public repositories and organizations";
case "repo": return "Read/write access to code in private repositories and organizations";
case "read:org": return "Read-only access to organizations (used to suggest organizations when forking a repository)";
case "workflow": return "Allow updating GitHub Actions workflow files";
// GitLab
case "read_user": return "Read-only access to your email addresses";
case "api": return "Allow making API calls (used to set up a webhook when enabling prebuilds for a repository)";
case "read_repository": return "Read/write access to your repositories";
// Bitbucket
case "account": return "Read-only access to your account information";
case "repository": return "Read-only access to your repositories (note: Bitbucket doesn't support revoking scopes)";
case "repository:write": return "Read/write access to your repositories (note: Bitbucket doesn't support revoking scopes)";
case "pullrequest": return "Read access to pull requests and ability to collaborate via comments, tasks, and approvals (note: Bitbucket doesn't support revoking scopes)";
case "pullrequest:write": return "Allow creating, merging and declining pull requests (note: Bitbucket doesn't support revoking scopes)";
case "webhook": return "Allow installing webhooks (used when enabling prebuilds for a repository, note: Bitbucket doesn't support revoking scopes)";
default: return undefined;
}
}

protected updateToken(provider: string, scopes: string[]) {
if (scopes.length === 0) {
return;
Expand Down
27 changes: 15 additions & 12 deletions components/dashboard/src/components/show-not-found-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ import { GitpodService, AuthProviderInfo } from '@gitpod/gitpod-protocol';

export namespace ShowNotFoundError {
export interface Props {
data: any;
data: {
readonly host: string;
readonly owner: string;
readonly repoName: string;
readonly userScopes?: string[];
readonly missingScopes?: string[];
readonly lastUpdate?: string;
readonly userIsOwner?: boolean;
};
service: GitpodService;
}
export interface State {
Expand Down Expand Up @@ -51,7 +59,7 @@ export default class ShowNotFoundError extends React.Component<ShowNotFoundError
}

protected renderMessage() {
const { host, owner, userIsOwner, userScopes, lastUpdate } = this.props.data;
const { host, owner, userIsOwner, missingScopes, lastUpdate } = this.props.data;

const authProviders = this.state.authProviders;
let message: JSX.Element | undefined;
Expand All @@ -60,10 +68,10 @@ export default class ShowNotFoundError extends React.Component<ShowNotFoundError
if (!provider) {
return undefined;
}
const missingScope = this.guessMissingScope(provider, userScopes);
const scopes = (missingScopes || []).join(",")
const link = new GitpodHostUrl(window.location.toString()).withApi({
pathname: '/authorize',
search: `returnTo=${encodeURIComponent(window.location.toString())}&host=${host}&scopes=${missingScope}`
search: `returnTo=${encodeURIComponent(window.location.toString())}&host=${host}&scopes=${scopes}`
}).toString();

let updatedRecently = false;
Expand All @@ -75,10 +83,9 @@ export default class ShowNotFoundError extends React.Component<ShowNotFoundError
// ignore
}
}
const privatePermission = this.privatePermissionGranted(userScopes, missingScope);
if (!privatePermission) {
if (missingScopes) {
message = (
<div className="text">The repository might be private. <a href={link}>Grant access to private repositories</a>.</div>
<div className="text">The repository might be private. <a href={link}>Grant access to repositories</a>.</div>
);
} else if (userIsOwner) {
message = (
Expand All @@ -87,7 +94,7 @@ export default class ShowNotFoundError extends React.Component<ShowNotFoundError
} else {
if (!updatedRecently) {
message = (
<div className="text">Permission to access private repositories has been granted. If you are a member of '{owner}', try to <a href={link}>request access</a> for Gitpod.</div>
<div className="text">Permission to access repositories has been granted. If you are a member of '{owner}', try to <a href={link}>request access</a> for Gitpod.</div>
);
} else {
message = (
Expand All @@ -99,10 +106,6 @@ export default class ShowNotFoundError extends React.Component<ShowNotFoundError
return message;
}

protected guessMissingScope(authProvider: AuthProviderInfo, userScopes: string[]) {
// TODO: this should be aware of already granted permissions
return authProvider.host === "github.com" ? "repo" : "read_repository";
}
protected privatePermissionGranted(userScopes: string[], missingScope: string): boolean {
return userScopes.indexOf(missingScope) > -1;
}
Expand Down
13 changes: 7 additions & 6 deletions components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1025,12 +1025,13 @@ export interface AuthProviderInfo {
readonly description?: string;

readonly settingsUrl?: string;
readonly scopes?: string[];
readonly requirements?: {
readonly default: string[];
readonly publicRepo: string[];
readonly privateRepo: string[];
}
readonly scopes: AuthProviderScopes;
}

export interface AuthProviderScopes {
readonly default: string[];
readonly all: string[];
readonly descriptions: { [key: string]: string };
}

export interface AuthProviderEntry {
Expand Down
8 changes: 4 additions & 4 deletions components/server/src/auth/authenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ export class Authenticator {
await AuthFlow.attach(req.session, { host, returnTo, overrideScopes: override });
let wantedScopes = scopes.split(',');
if (wantedScopes.length === 0) {
if (authProvider.info.requirements) {
wantedScopes = authProvider.info.requirements.default;
if (authProvider.info.scopes) {
wantedScopes = authProvider.info.scopes.default;
}
}
// compute merged scopes
Expand All @@ -211,8 +211,8 @@ export class Authenticator {
wantedScopes = this.mergeScopes(currentScopes, wantedScopes);
// in case user signed in with another identity, we need to ensure the merged scopes contain
// all default needed to for proper authentication
if (currentScopes.length === 0 && authProvider.info.requirements) {
wantedScopes = this.mergeScopes(authProvider.info.requirements.default, wantedScopes);
if (currentScopes.length === 0 && authProvider.info.scopes) {
wantedScopes = this.mergeScopes(authProvider.info.scopes.default, wantedScopes);
}
}
// authorize Gitpod
Expand Down
15 changes: 7 additions & 8 deletions components/server/src/auth/generic-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class GenericAuthProvider implements AuthProvider {
}

protected defaultInfo(): AuthProviderInfo {
const scopes = this.oauthScopes;
const { oauthScopes } = this;
const { id, type, icon, host, ownerId, verified, hiddenOnDashboard, disallowLogin, description, loginContextMatcher } = this.config;
return {
authProviderId: id,
Expand All @@ -93,13 +93,12 @@ export class GenericAuthProvider implements AuthProvider {
loginContextMatcher,
disallowLogin,
description,
scopes,
scopes: {
all: oauthScopes,
default: oauthScopes,
descriptions: {}
},
settingsUrl: this.oauthConfig.settingsUrl,
requirements: {
default: scopes,
publicRepo: scopes,
privateRepo: scopes
}
}
}

Expand All @@ -119,7 +118,7 @@ export class GenericAuthProvider implements AuthProvider {
protected get oauthConfig() {
return this.config.oauth!;
}
protected get oauthScopes() {
protected get oauthScopes(): string[] {
if (!this.oauthConfig.scope) {
return [];
}
Expand Down
13 changes: 4 additions & 9 deletions components/server/src/bitbucket/bitbucket-auth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,7 @@ export class BitbucketAuthProvider extends GenericAuthProvider {
get info(): AuthProviderInfo {
return {
...this.defaultInfo(),
scopes: BitbucketOAuthScopes.ALL,
requirements: {
default: BitbucketOAuthScopes.Requirements.DEFAULT,
publicRepo: BitbucketOAuthScopes.Requirements.DEFAULT,
privateRepo: BitbucketOAuthScopes.Requirements.DEFAULT,
},
scopes: BitbucketOAuthScopes.definitions,
}
}

Expand All @@ -39,7 +34,7 @@ export class BitbucketAuthProvider extends GenericAuthProvider {
authorizationUrl: oauth.authorizationUrl || `https://${this.config.host}/site/oauth2/authorize`,
tokenUrl: oauth.tokenUrl || `https://${this.config.host}/site/oauth2/access_token`,
settingsUrl: oauth.settingsUrl || `https://${this.config.host}/account/settings/app-authorizations/`,
scope: BitbucketOAuthScopes.ALL.join(scopeSeparator),
scope: BitbucketOAuthScopes.definitions.default.join(scopeSeparator),
scopeSeparator
};
}
Expand All @@ -49,7 +44,7 @@ export class BitbucketAuthProvider extends GenericAuthProvider {
}

authorize(req: express.Request, res: express.Response, next: express.NextFunction, scope?: string[]): void {
super.authorize(req, res, next, scope ? scope : BitbucketOAuthScopes.Requirements.DEFAULT);
super.authorize(req, res, next, scope ? scope : BitbucketOAuthScopes.definitions.default);
}

protected get baseURL() {
Expand Down Expand Up @@ -105,7 +100,7 @@ export class BitbucketAuthProvider extends GenericAuthProvider {
set.add('pullrequest');
}
for (const item of set.values()) {
if (!(BitbucketOAuthScopes.Requirements.DEFAULT.includes(item))) {
if (!(BitbucketOAuthScopes.definitions.default.includes(item))) {
set.delete(item);
}
}
Expand Down
8 changes: 7 additions & 1 deletion components/server/src/bitbucket/bitbucket-context-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export class BitbucketContextParser extends AbstractContextParser implements ICo
span.log({ "request.finished": "" });

if (!repo) {
throw await NotFoundError.create(await this.tokenHelper.getCurrentToken(user), user, this.config.host, owner, repoName);
throw await this.createNotFoundError(user, host, owner, repoName);
}

if (!more.revision)
Expand Down Expand Up @@ -168,6 +168,12 @@ export class BitbucketContextParser extends AbstractContextParser implements ICo
}
}

protected async createNotFoundError(user: User, host: string, owner: string, repoName: string) {
const authProviderId = this.authProviderId;
const token = await this.tokenHelper.getCurrentToken(user);
return NotFoundError.create(token, user, host, owner, repoName, authProviderId, []);
}

protected async handlePullRequestContext(ctx: TraceContext, user: User, host: string, owner: string, repoName: string, more: Partial<PullRequestContext> & { nr: number }): Promise<PullRequestContext> {
const span = TraceContext.startSpan("BitbucketContextParser.handleIssueContext", ctx);
try {
Expand Down
22 changes: 14 additions & 8 deletions components/server/src/bitbucket/bitbucket-oauth-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* See License-AGPL.txt in the project root for license information.
*/

import { AuthProviderScopes } from "@gitpod/gitpod-protocol";

// https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html

export namespace BitbucketOAuthScopes {
Expand All @@ -20,12 +22,16 @@ export namespace BitbucketOAuthScopes {
/** Create, list web hooks */
export const WEBHOOK = "webhook"

export const ALL = [ACCOUNT_READ, REPOSITORY_READ, REPOSITORY_WRITE, PULL_REQUEST_READ, PULL_REQUEST_WRITE, WEBHOOK];

export const Requirements = {
/**
* Minimal required permission.
*/
DEFAULT: ALL
}
export const definitions: AuthProviderScopes = {
default: [ACCOUNT_READ, REPOSITORY_READ, REPOSITORY_WRITE, PULL_REQUEST_READ, PULL_REQUEST_WRITE, WEBHOOK],
all: [ACCOUNT_READ, REPOSITORY_READ, REPOSITORY_WRITE, PULL_REQUEST_READ, PULL_REQUEST_WRITE, WEBHOOK],
descriptions: {
ACCOUNT_READ: "Grants read-only access to your account information.",
REPOSITORY_READ: "Read-only access to your repositories (note: Bitbucket doesn't support revoking scopes)",
REPOSITORY_WRITE: "Grants read/write access to your repositories (note: Bitbucket doesn't support revoking scopes)",
PULL_REQUEST_READ: "Grants read-only access to pull requests and ability to collaborate via comments, tasks, and approvals (note: Bitbucket doesn't support revoking scopes)",
PULL_REQUEST_WRITE: "Allows creating, merging and declining pull requests (note: Bitbucket doesn't support revoking scopes)",
WEBHOOK: "Allows installing webhooks (used when enabling prebuilds for a repository, note: Bitbucket doesn't support revoking scopes)",
}
};
}
2 changes: 1 addition & 1 deletion components/server/src/bitbucket/bitbucket-token-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class BitbucketTokenHelper {
// no token
}
if (requiredScopes.length === 0) {
requiredScopes = BitbucketOAuthScopes.Requirements.DEFAULT
requiredScopes = BitbucketOAuthScopes.definitions.default
}
throw UnauthorizedError.create(host, requiredScopes, "missing-identity");
}
Expand Down
Loading