Skip to content
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

Initial version of OAuth server to manage access to Gitpod workspaces #4222

Merged
merged 1 commit into from
May 21, 2021
Merged
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
1 change: 1 addition & 0 deletions .werft/values.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ components:
makeNewUsersAdmin: true # for development
theiaPluginsBucketName: gitpod-core-dev-plugins
enableLocalApp: true
enableOAuthServer: true
blockNewUsers: true
blockNewUsersPasslist:
- "gitpod.io"
Expand Down
6 changes: 6 additions & 0 deletions chart/templates/server-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ spec:
- name: ENABLE_LOCAL_APP
value: "true"
{{- end }}
{{- if $comp.enableOAuthServer }}
- name: ENABLE_OAUTH_SERVER
value: "true"
{{- end }}
{{- if $comp.portAccessForUsersOnly }}
- name: PORT_ACCESS_FOR_USERS_ONLY
value: "true"
Expand Down Expand Up @@ -216,6 +220,8 @@ spec:
key: apikey
- name: GITPOD_GARBAGE_COLLECTION_DISABLED
value: {{ $comp.garbageCollection.disabled | default "false" | quote }}
- name: OAUTH_SERVER_JWT_SECRET
value: {{ (randAlphaNum 20) | quote }}
{{- if $comp.serverContainer.env }}
{{ toYaml $comp.serverContainer.env | indent 8 }}
{{- end }}
Expand Down
7 changes: 6 additions & 1 deletion components/dashboard/public/complete-auth/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
<title>Done</title>
<script>
if (window.opener) {
const message = new URLSearchParams(window.location.search).get("message");
const search = new URLSearchParams(window.location.search);
let message = search.get("message");
const returnTo = search.get("returnTo");
if (returnTo) {
message = `${message}&returnTo=${encodeURIComponent(returnTo)}`;
}
window.opener.postMessage(message, `https://${window.location.hostname}`);
} else {
console.log("This page is supposed to be opened by Gitpod.")
Expand Down
8 changes: 8 additions & 0 deletions components/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './
const FromReferrer = React.lazy(() => import(/* webpackPrefetch: true */ './FromReferrer'));
const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/UserSearch'));
const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/WorkspacesSearch'));
const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ './OauthClientApproval'));

function Loading() {
return <>
Expand Down Expand Up @@ -118,6 +119,13 @@ function App() {
if (shouldWhatsNewShown !== isWhatsNewShown) {
setWhatsNewShown(shouldWhatsNewShown);
}
if (window.location.pathname.startsWith('/oauth-approval')) {
rl-gitpod marked this conversation as resolved.
Show resolved Hide resolved
return (
<Suspense fallback={<Loading />}>
<OAuthClientApproval />
</Suspense>
);
}

window.addEventListener("hashchange", () => {
// Refresh on hash change if the path is '/' (new context URL)
Expand Down
32 changes: 21 additions & 11 deletions components/dashboard/src/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { AuthProviderInfo } from "@gitpod/gitpod-protocol";
import { useContext, useEffect, useState } from "react";
import { UserContext } from "./user-context";
import { getGitpodService } from "./service/service";
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from "./provider-utils";
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName, getSafeURLRedirect } from "./provider-utils";
import gitpod from './images/gitpod.svg';
import gitpodDark from './images/gitpod-dark.svg';
import gitpodIcon from './icons/gitpod.svg';
Expand Down Expand Up @@ -50,14 +50,31 @@ export function Login() {
})();
}, [])

const authorizeSuccessful = async (payload?: string) => {
updateUser();
// Check for a valid returnTo in payload
const safeReturnTo = getSafeURLRedirect(payload);
if (safeReturnTo) {
// ... and if it is, redirect to it
window.location.replace(safeReturnTo);
}
}

const updateUser = async () => {
await getGitpodService().reconnect();
const user = await getGitpodService().server.getLoggedInUser();
setUser(user);
markLoggedIn();
}

const openLogin = async (host: string) => {
setErrorMessage(undefined);

try {
await openAuthorizeWindow({
login: true,
host,
onSuccess: () => updateUser(),
onSuccess: authorizeSuccessful,
onError: (payload) => {
let errorMessage: string;
if (typeof payload === "string") {
Expand All @@ -76,20 +93,13 @@ export function Login() {
}
}

const updateUser = async () => {
await getGitpodService().reconnect();
const user = await getGitpodService().server.getLoggedInUser();
setUser(user);
markLoggedIn();
}

return (<div id="login-container" className="z-50 flex w-screen h-screen">
{showWelcome ? <div id="feature-section" className="flex-grow bg-gray-100 dark:bg-gray-800 w-1/2 hidden lg:block">
<div id="feature-section-column" className="flex max-w-xl h-full mx-auto pt-6">
<div className="flex flex-col px-8 my-auto ml-auto">
<div className="mb-12">
<img src={gitpod} className="h-8 block dark:hidden"/>
<img src={gitpodDark} className="h-8 hidden dark:block"/>
<img src={gitpod} className="h-8 block dark:hidden" />
<img src={gitpodDark} className="h-8 hidden dark:block" />
</div>
<div className="mb-10">
<h1 className="text-5xl mb-3">Welcome to Gitpod</h1>
Expand Down
48 changes: 48 additions & 0 deletions components/dashboard/src/OauthClientApproval.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import gitpodIcon from './icons/gitpod.svg';
import { getSafeURLRedirect } from "./provider-utils";

export default function OAuthClientApproval() {
const params = new URLSearchParams(window.location.search);
const clientName = params.get("clientName") || "";
let redirectToParam = params.get("redirectTo") || undefined;
if (redirectToParam) {
redirectToParam = decodeURIComponent(redirectToParam);
}
const redirectTo = getSafeURLRedirect(redirectToParam) || "/";
const updateClientApproval = async (isApproved: boolean) => {
if (redirectTo === "/") {
window.location.replace(redirectTo);
}
window.location.replace(`${redirectTo}&approved=${isApproved ? 'yes' : 'no'}`);
}

return (<div id="oauth-container" className="z-50 flex w-screen h-screen">
<div id="oauth-section" className="flex-grow flex w-full">
<div id="oauth-section-column" className="flex-grow max-w-2xl flex flex-col h-100 mx-auto">
<div className="flex-grow h-100 flex flex-row items-center justify-center" >
<div className="rounded-xl px-10 py-10 mx-auto">
<div className="mx-auto pb-8">
<img src={gitpodIcon} className="h-16 mx-auto" />
</div>
<div className="mx-auto text-center pb-8 space-y-2">
<h1 className="text-3xl">Authorize {clientName}</h1>
<h4>You are about to authorize {clientName} to access your Gitpod account including data for all workspaces.</h4>
</div>
<div className="flex justify-center mt-6">
<button className="secondary" onClick={() => updateClientApproval(false)}>Cancel</button>
<button key={"button-yes"} className="ml-2" onClick={() => updateClientApproval(true)}>
Authorize
</button>
</div>
</div>
</div>
</div>
</div>
</div>);
}
20 changes: 18 additions & 2 deletions components/dashboard/src/provider-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,19 @@ interface OpenAuthorizeWindowParams {
host: string;
scopes?: string[];
overrideScopes?: boolean;
overrideReturn?: string;
onSuccess?: (payload?: string) => void;
onError?: (error: string | { error: string, description?: string }) => void;
}

async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
const { login, host, scopes, overrideScopes, onSuccess, onError } = params;
const returnTo = gitpodHostUrl.with({ pathname: 'complete-auth', search: 'message=success' }).toString();
let search = 'message=success';
const redirectURL = getSafeURLRedirect();
if (redirectURL) {
search = `${search}&returnTo=${encodeURIComponent(redirectURL)}`
}
const returnTo = gitpodHostUrl.with({ pathname: 'complete-auth', search: search }).toString();
const requestedScopes = scopes || [];
const url = login
? gitpodHostUrl.withApi({
Expand Down Expand Up @@ -94,5 +100,15 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
};
window.addEventListener("message", eventListener);
}
const getSafeURLRedirect = (source?: string) => {
const returnToURL: string | null = new URLSearchParams(source ? source : window.location.search).get("returnTo");
if (returnToURL) {
// Only allow oauth on the same host
if (returnToURL.toLowerCase().startsWith(`${window.location.protocol}//${window.location.host}/api/oauth/`.toLowerCase())) {
return returnToURL;
}
}
}


export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow }
export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow, getSafeURLRedirect }
17 changes: 17 additions & 0 deletions components/gitpod-db/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Gitpod-db

Contains all the database related functionality, implemented using [typeorm](https://typeorm.io/).

### Adding a new table
1. Create a [migration](./src/typeorm/migration/README.md) - use the [baseline](./src/typeorm/migration/1592203031938-Baseline.ts) as an exemplar
1. Create a new entity that implements the requisite interface or extend an existing entity as required - see [db-user.ts](./src/typeorm/entity/db-user.ts)
1. If it is a new table, create the matching injectable ORM implementation and interface (if required) - see [user-db-impl.ts](./src/typeorm/user-db-impl.ts) and [user-db.ts](./src/user-db.ts). Otherwise extend the existing interface and implementation as required.
1. Add the injectable implementation to the [DB container module](./src/container-module.ts), binding the interface and implementation as appropriate, otherwise it will not be instantiated correctly e.g.
```
bind(TypeORMUserDBImpl).toSelf().inSingletonScope();
bind(UserDB).toService(TypeORMUserDBImpl);
```
1. Add the new ORM as an injected component where required e.g. in [user-controller.ts](./src/user/user-controller.ts)
```
@inject(UserDB) protected readonly userDb: UserDB;
```
7 changes: 4 additions & 3 deletions components/gitpod-db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
],
"dependencies": {
"@gitpod/gitpod-protocol": "0.1.5",
"@jmondi/oauth2-server": "^1.1.0",
"mysql": "^2.15.0",
"reflect-metadata": "^0.1.10",
"typeorm": "0.1.20",
Expand All @@ -34,11 +35,11 @@
"@types/chai": "^4.2.2",
"@types/mysql": "^2.15.0",
"@types/uuid": "^3.1.0",
"rimraf": "^2.6.1",
"chai": "^4.2.0",
"mocha": "^4.1.0",
"mocha-typescript": "^1.1.17",
"chai": "^4.2.0",
"rimraf": "^2.6.1",
"ts-node": "<7.0.0",
"typescript": "~4.1.2"
}
}
}
3 changes: 3 additions & 0 deletions components/gitpod-db/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { TermsAcceptanceDBImpl } from './typeorm/terms-acceptance-db-impl';
import { CodeSyncResourceDB } from './typeorm/code-sync-resource-db';
import { WorkspaceClusterDBImpl } from './typeorm/workspace-cluster-db-impl';
import { WorkspaceClusterDB } from './workspace-cluster-db';
import { AuthCodeRepositoryDB } from './typeorm/auth-code-repository-db';

// THE DB container module that contains all DB implementations
export const dbContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
Expand Down Expand Up @@ -89,4 +90,6 @@ export const dbContainerModule = new ContainerModule((bind, unbind, isBound, reb
bind(CodeSyncResourceDB).toSelf().inSingletonScope();

bind(WorkspaceClusterDB).to(WorkspaceClusterDBImpl).inSingletonScope();

bind(AuthCodeRepositoryDB).toSelf().inSingletonScope();
});
67 changes: 67 additions & 0 deletions components/gitpod-db/src/typeorm/auth-code-repository-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { DateInterval, OAuthAuthCode, OAuthAuthCodeRepository, OAuthClient, OAuthScope, OAuthUser } from "@jmondi/oauth2-server";
import * as crypto from 'crypto';
import { inject, injectable } from "inversify";
import { EntityManager, Repository } from "typeorm";
import { DBOAuthAuthCodeEntry } from './entity/db-oauth-auth-code';
import { TypeORM } from './typeorm';

const expiryInFuture = new DateInterval("5m");

@injectable()
export class AuthCodeRepositoryDB implements OAuthAuthCodeRepository {

@inject(TypeORM)
private readonly typeORM: TypeORM;

protected async getEntityManager(): Promise<EntityManager> {
return (await this.typeORM.getConnection()).manager;
}

async getOauthAuthCodeRepo(): Promise<Repository<DBOAuthAuthCodeEntry>> {
return (await this.getEntityManager()).getRepository<DBOAuthAuthCodeEntry>(DBOAuthAuthCodeEntry);
}

public async getByIdentifier(authCodeCode: string): Promise<OAuthAuthCode> {
const authCodeRepo = await this.getOauthAuthCodeRepo();
let authCodes = await authCodeRepo.find({ code: authCodeCode });
authCodes = authCodes.filter(te => (new Date(te.expiresAt)).getTime() > Date.now());
const authCode = authCodes.length > 0 ? authCodes[0] : undefined;
if (!authCode) {
throw new Error(`authentication code not found`);
}
return authCode;
}
public issueAuthCode(client: OAuthClient, user: OAuthUser | undefined, scopes: OAuthScope[]): OAuthAuthCode {
const code = crypto.randomBytes(30).toString('hex');
// NOTE: caller (@jmondi/oauth2-server) is responsible for adding the remaining items, PKCE params, redirect URL, etc
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on #4164 (comment)

given, that this is called by the oauth2 server framework, I'd suggest to extract and move it to the server component. it seems odd to implement the missing bits for the framework within gitpod-db.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That requires moving the entire AuthCodeRepositoryDB (OAuthAuthCodeRepository) implementation to server and since it requires storage, then adding a db service + implementation for it to use.

I agree it meets the current, very structured implementation approach for most parts of the system that interact with the db and will take a look at it tomorrow.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That requires moving the entire AuthCodeRepositoryDB (OAuthAuthCodeRepository) implementation to server

no it doesn't.

this pattern is used in may cases here. the DB implementation lives in gitpod-db and is injected into services from server.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

odd to implement the missing bits for the framework within gitpod-db.

Not so much a missing bit but the OAuthAuthCodeRepository interface needs to be implemented for the framework to use. Moving 1 requires moving the rest (yes - there are other, less readable ways to do so).

this pattern is used in may cases here. the DB implementation lives in gitpod-db and is injected into services from server.

Agreed. That's what I was referring to with "then adding a db service + implementation for it to use"

return {
code: code,
user,
client,
expiresAt: expiryInFuture.getEndDate(),
scopes: scopes,
};
}
public async persist(authCode: OAuthAuthCode): Promise<void> {
const authCodeRepo = await this.getOauthAuthCodeRepo();
authCodeRepo.save(authCode);
}
public async isRevoked(authCodeCode: string): Promise<boolean> {
const authCode = await this.getByIdentifier(authCodeCode);
return Date.now() > authCode.expiresAt.getTime();
}
public async revoke(authCodeCode: string): Promise<void> {
const authCode = await this.getByIdentifier(authCodeCode);
if (authCode) {
// Set date to earliest timestamp that MySQL allows
authCode.expiresAt = new Date(1000);
return this.persist(authCode);
}
}
}
Loading