Skip to content

Commit

Permalink
[server] add OAuth2 server endpoints (behind a feature flag)
Browse files Browse the repository at this point in the history
to manage client application access to users Gitpod workspaces
  • Loading branch information
rl-gitpod committed May 21, 2021
1 parent f7b2e37 commit 8de1006
Show file tree
Hide file tree
Showing 32 changed files with 957 additions and 35 deletions.
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')) {
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
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

0 comments on commit 8de1006

Please sign in to comment.