Skip to content

Commit f96392a

Browse files
authored
[server] add OAuth2 server endpoints (behind a feature flag) (#4222)
to manage client application access to users Gitpod workspaces
1 parent 617b98a commit f96392a

32 files changed

+957
-35
lines changed

.werft/values.dev.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ components:
3131
makeNewUsersAdmin: true # for development
3232
theiaPluginsBucketName: gitpod-core-dev-plugins
3333
enableLocalApp: true
34+
enableOAuthServer: true
3435
blockNewUsers: true
3536
blockNewUsersPasslist:
3637
- "gitpod.io"

chart/templates/server-deployment.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,10 @@ spec:
170170
- name: ENABLE_LOCAL_APP
171171
value: "true"
172172
{{- end }}
173+
{{- if $comp.enableOAuthServer }}
174+
- name: ENABLE_OAUTH_SERVER
175+
value: "true"
176+
{{- end }}
173177
{{- if $comp.portAccessForUsersOnly }}
174178
- name: PORT_ACCESS_FOR_USERS_ONLY
175179
value: "true"
@@ -216,6 +220,8 @@ spec:
216220
key: apikey
217221
- name: GITPOD_GARBAGE_COLLECTION_DISABLED
218222
value: {{ $comp.garbageCollection.disabled | default "false" | quote }}
223+
- name: OAUTH_SERVER_JWT_SECRET
224+
value: {{ (randAlphaNum 20) | quote }}
219225
{{- if $comp.serverContainer.env }}
220226
{{ toYaml $comp.serverContainer.env | indent 8 }}
221227
{{- end }}

components/dashboard/public/complete-auth/index.html

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@
1111
<title>Done</title>
1212
<script>
1313
if (window.opener) {
14-
const message = new URLSearchParams(window.location.search).get("message");
14+
const search = new URLSearchParams(window.location.search);
15+
let message = search.get("message");
16+
const returnTo = search.get("returnTo");
17+
if (returnTo) {
18+
message = `${message}&returnTo=${encodeURIComponent(returnTo)}`;
19+
}
1520
window.opener.postMessage(message, `https://${window.location.hostname}`);
1621
} else {
1722
console.log("This page is supposed to be opened by Gitpod.")

components/dashboard/src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const InstallGitHubApp = React.lazy(() => import(/* webpackPrefetch: true */ './
3434
const FromReferrer = React.lazy(() => import(/* webpackPrefetch: true */ './FromReferrer'));
3535
const UserSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/UserSearch'));
3636
const WorkspacesSearch = React.lazy(() => import(/* webpackPrefetch: true */ './admin/WorkspacesSearch'));
37+
const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ './OauthClientApproval'));
3738

3839
function Loading() {
3940
return <>
@@ -118,6 +119,13 @@ function App() {
118119
if (shouldWhatsNewShown !== isWhatsNewShown) {
119120
setWhatsNewShown(shouldWhatsNewShown);
120121
}
122+
if (window.location.pathname.startsWith('/oauth-approval')) {
123+
return (
124+
<Suspense fallback={<Loading />}>
125+
<OAuthClientApproval />
126+
</Suspense>
127+
);
128+
}
121129

122130
window.addEventListener("hashchange", () => {
123131
// Refresh on hash change if the path is '/' (new context URL)

components/dashboard/src/Login.tsx

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { AuthProviderInfo } from "@gitpod/gitpod-protocol";
88
import { useContext, useEffect, useState } from "react";
99
import { UserContext } from "./user-context";
1010
import { getGitpodService } from "./service/service";
11-
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName } from "./provider-utils";
11+
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName, getSafeURLRedirect } from "./provider-utils";
1212
import gitpod from './images/gitpod.svg';
1313
import gitpodDark from './images/gitpod-dark.svg';
1414
import gitpodIcon from './icons/gitpod.svg';
@@ -50,14 +50,31 @@ export function Login() {
5050
})();
5151
}, [])
5252

53+
const authorizeSuccessful = async (payload?: string) => {
54+
updateUser();
55+
// Check for a valid returnTo in payload
56+
const safeReturnTo = getSafeURLRedirect(payload);
57+
if (safeReturnTo) {
58+
// ... and if it is, redirect to it
59+
window.location.replace(safeReturnTo);
60+
}
61+
}
62+
63+
const updateUser = async () => {
64+
await getGitpodService().reconnect();
65+
const user = await getGitpodService().server.getLoggedInUser();
66+
setUser(user);
67+
markLoggedIn();
68+
}
69+
5370
const openLogin = async (host: string) => {
5471
setErrorMessage(undefined);
5572

5673
try {
5774
await openAuthorizeWindow({
5875
login: true,
5976
host,
60-
onSuccess: () => updateUser(),
77+
onSuccess: authorizeSuccessful,
6178
onError: (payload) => {
6279
let errorMessage: string;
6380
if (typeof payload === "string") {
@@ -76,20 +93,13 @@ export function Login() {
7693
}
7794
}
7895

79-
const updateUser = async () => {
80-
await getGitpodService().reconnect();
81-
const user = await getGitpodService().server.getLoggedInUser();
82-
setUser(user);
83-
markLoggedIn();
84-
}
85-
8696
return (<div id="login-container" className="z-50 flex w-screen h-screen">
8797
{showWelcome ? <div id="feature-section" className="flex-grow bg-gray-100 dark:bg-gray-800 w-1/2 hidden lg:block">
8898
<div id="feature-section-column" className="flex max-w-xl h-full mx-auto pt-6">
8999
<div className="flex flex-col px-8 my-auto ml-auto">
90100
<div className="mb-12">
91-
<img src={gitpod} className="h-8 block dark:hidden"/>
92-
<img src={gitpodDark} className="h-8 hidden dark:block"/>
101+
<img src={gitpod} className="h-8 block dark:hidden" />
102+
<img src={gitpodDark} className="h-8 hidden dark:block" />
93103
</div>
94104
<div className="mb-10">
95105
<h1 className="text-5xl mb-3">Welcome to Gitpod</h1>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import gitpodIcon from './icons/gitpod.svg';
8+
import { getSafeURLRedirect } from "./provider-utils";
9+
10+
export default function OAuthClientApproval() {
11+
const params = new URLSearchParams(window.location.search);
12+
const clientName = params.get("clientName") || "";
13+
let redirectToParam = params.get("redirectTo") || undefined;
14+
if (redirectToParam) {
15+
redirectToParam = decodeURIComponent(redirectToParam);
16+
}
17+
const redirectTo = getSafeURLRedirect(redirectToParam) || "/";
18+
const updateClientApproval = async (isApproved: boolean) => {
19+
if (redirectTo === "/") {
20+
window.location.replace(redirectTo);
21+
}
22+
window.location.replace(`${redirectTo}&approved=${isApproved ? 'yes' : 'no'}`);
23+
}
24+
25+
return (<div id="oauth-container" className="z-50 flex w-screen h-screen">
26+
<div id="oauth-section" className="flex-grow flex w-full">
27+
<div id="oauth-section-column" className="flex-grow max-w-2xl flex flex-col h-100 mx-auto">
28+
<div className="flex-grow h-100 flex flex-row items-center justify-center" >
29+
<div className="rounded-xl px-10 py-10 mx-auto">
30+
<div className="mx-auto pb-8">
31+
<img src={gitpodIcon} className="h-16 mx-auto" />
32+
</div>
33+
<div className="mx-auto text-center pb-8 space-y-2">
34+
<h1 className="text-3xl">Authorize {clientName}</h1>
35+
<h4>You are about to authorize {clientName} to access your Gitpod account including data for all workspaces.</h4>
36+
</div>
37+
<div className="flex justify-center mt-6">
38+
<button className="secondary" onClick={() => updateClientApproval(false)}>Cancel</button>
39+
<button key={"button-yes"} className="ml-2" onClick={() => updateClientApproval(true)}>
40+
Authorize
41+
</button>
42+
</div>
43+
</div>
44+
</div>
45+
</div>
46+
</div>
47+
</div>);
48+
}

components/dashboard/src/provider-utils.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,19 @@ interface OpenAuthorizeWindowParams {
4040
host: string;
4141
scopes?: string[];
4242
overrideScopes?: boolean;
43+
overrideReturn?: string;
4344
onSuccess?: (payload?: string) => void;
4445
onError?: (error: string | { error: string, description?: string }) => void;
4546
}
4647

4748
async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
4849
const { login, host, scopes, overrideScopes, onSuccess, onError } = params;
49-
const returnTo = gitpodHostUrl.with({ pathname: 'complete-auth', search: 'message=success' }).toString();
50+
let search = 'message=success';
51+
const redirectURL = getSafeURLRedirect();
52+
if (redirectURL) {
53+
search = `${search}&returnTo=${encodeURIComponent(redirectURL)}`
54+
}
55+
const returnTo = gitpodHostUrl.with({ pathname: 'complete-auth', search: search }).toString();
5056
const requestedScopes = scopes || [];
5157
const url = login
5258
? gitpodHostUrl.withApi({
@@ -94,5 +100,15 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
94100
};
95101
window.addEventListener("message", eventListener);
96102
}
103+
const getSafeURLRedirect = (source?: string) => {
104+
const returnToURL: string | null = new URLSearchParams(source ? source : window.location.search).get("returnTo");
105+
if (returnToURL) {
106+
// Only allow oauth on the same host
107+
if (returnToURL.toLowerCase().startsWith(`${window.location.protocol}//${window.location.host}/api/oauth/`.toLowerCase())) {
108+
return returnToURL;
109+
}
110+
}
111+
}
112+
97113

98-
export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow }
114+
export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow, getSafeURLRedirect }

components/gitpod-db/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## Gitpod-db
2+
3+
Contains all the database related functionality, implemented using [typeorm](https://typeorm.io/).
4+
5+
### Adding a new table
6+
1. Create a [migration](./src/typeorm/migration/README.md) - use the [baseline](./src/typeorm/migration/1592203031938-Baseline.ts) as an exemplar
7+
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)
8+
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.
9+
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.
10+
```
11+
bind(TypeORMUserDBImpl).toSelf().inSingletonScope();
12+
bind(UserDB).toService(TypeORMUserDBImpl);
13+
```
14+
1. Add the new ORM as an injected component where required e.g. in [user-controller.ts](./src/user/user-controller.ts)
15+
```
16+
@inject(UserDB) protected readonly userDb: UserDB;
17+
```

components/gitpod-db/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
],
2626
"dependencies": {
2727
"@gitpod/gitpod-protocol": "0.1.5",
28+
"@jmondi/oauth2-server": "^1.1.0",
2829
"mysql": "^2.15.0",
2930
"reflect-metadata": "^0.1.10",
3031
"typeorm": "0.1.20",
@@ -34,11 +35,11 @@
3435
"@types/chai": "^4.2.2",
3536
"@types/mysql": "^2.15.0",
3637
"@types/uuid": "^3.1.0",
37-
"rimraf": "^2.6.1",
38+
"chai": "^4.2.0",
3839
"mocha": "^4.1.0",
3940
"mocha-typescript": "^1.1.17",
40-
"chai": "^4.2.0",
41+
"rimraf": "^2.6.1",
4142
"ts-node": "<7.0.0",
4243
"typescript": "~4.1.2"
4344
}
44-
}
45+
}

components/gitpod-db/src/container-module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { TermsAcceptanceDBImpl } from './typeorm/terms-acceptance-db-impl';
3535
import { CodeSyncResourceDB } from './typeorm/code-sync-resource-db';
3636
import { WorkspaceClusterDBImpl } from './typeorm/workspace-cluster-db-impl';
3737
import { WorkspaceClusterDB } from './workspace-cluster-db';
38+
import { AuthCodeRepositoryDB } from './typeorm/auth-code-repository-db';
3839

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

9192
bind(WorkspaceClusterDB).to(WorkspaceClusterDBImpl).inSingletonScope();
93+
94+
bind(AuthCodeRepositoryDB).toSelf().inSingletonScope();
9295
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { DateInterval, OAuthAuthCode, OAuthAuthCodeRepository, OAuthClient, OAuthScope, OAuthUser } from "@jmondi/oauth2-server";
8+
import * as crypto from 'crypto';
9+
import { inject, injectable } from "inversify";
10+
import { EntityManager, Repository } from "typeorm";
11+
import { DBOAuthAuthCodeEntry } from './entity/db-oauth-auth-code';
12+
import { TypeORM } from './typeorm';
13+
14+
const expiryInFuture = new DateInterval("5m");
15+
16+
@injectable()
17+
export class AuthCodeRepositoryDB implements OAuthAuthCodeRepository {
18+
19+
@inject(TypeORM)
20+
private readonly typeORM: TypeORM;
21+
22+
protected async getEntityManager(): Promise<EntityManager> {
23+
return (await this.typeORM.getConnection()).manager;
24+
}
25+
26+
async getOauthAuthCodeRepo(): Promise<Repository<DBOAuthAuthCodeEntry>> {
27+
return (await this.getEntityManager()).getRepository<DBOAuthAuthCodeEntry>(DBOAuthAuthCodeEntry);
28+
}
29+
30+
public async getByIdentifier(authCodeCode: string): Promise<OAuthAuthCode> {
31+
const authCodeRepo = await this.getOauthAuthCodeRepo();
32+
let authCodes = await authCodeRepo.find({ code: authCodeCode });
33+
authCodes = authCodes.filter(te => (new Date(te.expiresAt)).getTime() > Date.now());
34+
const authCode = authCodes.length > 0 ? authCodes[0] : undefined;
35+
if (!authCode) {
36+
throw new Error(`authentication code not found`);
37+
}
38+
return authCode;
39+
}
40+
public issueAuthCode(client: OAuthClient, user: OAuthUser | undefined, scopes: OAuthScope[]): OAuthAuthCode {
41+
const code = crypto.randomBytes(30).toString('hex');
42+
// NOTE: caller (@jmondi/oauth2-server) is responsible for adding the remaining items, PKCE params, redirect URL, etc
43+
return {
44+
code: code,
45+
user,
46+
client,
47+
expiresAt: expiryInFuture.getEndDate(),
48+
scopes: scopes,
49+
};
50+
}
51+
public async persist(authCode: OAuthAuthCode): Promise<void> {
52+
const authCodeRepo = await this.getOauthAuthCodeRepo();
53+
authCodeRepo.save(authCode);
54+
}
55+
public async isRevoked(authCodeCode: string): Promise<boolean> {
56+
const authCode = await this.getByIdentifier(authCodeCode);
57+
return Date.now() > authCode.expiresAt.getTime();
58+
}
59+
public async revoke(authCodeCode: string): Promise<void> {
60+
const authCode = await this.getByIdentifier(authCodeCode);
61+
if (authCode) {
62+
// Set date to earliest timestamp that MySQL allows
63+
authCode.expiresAt = new Date(1000);
64+
return this.persist(authCode);
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)