Skip to content

Commit 7ecc196

Browse files
Sign in with SSO (#17055)
* [experiment] Add "Sign in with SSO" to Login Reusing existing parts: * `/complete-auth` page of Dashbaord to forward results of authN flows running in a modal * Adding preliminary UI to the Login view: Org-slug and simple button. * [gitpod-db] get team/org by slug * [gitpod-db] fix OIDCClientConfig.OrganizationID field's type * [oidc] consider returnTo URL * [oidc] consider orgSlug param from start request * [oidc] fix oauth2 clientId propagation * [oidc] fix a flaky test * [onboarding] skip for organizational accounts * Move SSO Login UI into it's own component * adjust validation a bit, add useCallbacks * adding GetOIDCClientConfigByOrgSlug * add table name * removing commented out code --------- Co-authored-by: Brad Harris <[email protected]>
1 parent d405f4b commit 7ecc196

File tree

15 files changed

+419
-124
lines changed

15 files changed

+419
-124
lines changed

components/dashboard/src/Login.tsx

Lines changed: 50 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { AuthProviderInfo } from "@gitpod/gitpod-protocol";
88
import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie";
9-
import { useContext, useEffect, useMemo, useState } from "react";
9+
import { useContext, useEffect, useState, useMemo, useCallback } from "react";
1010
import { UserContext } from "./user-context";
1111
import { getGitpodService } from "./service/service";
1212
import { iconForAuthProvider, openAuthorizeWindow, simplifyProviderName, getSafeURLRedirect } from "./provider-utils";
@@ -23,6 +23,7 @@ import exclamation from "./images/exclamation.svg";
2323
import { getURLHash } from "./utils";
2424
import ErrorMessage from "./components/ErrorMessage";
2525
import { Heading1, Heading2, Subheading } from "./components/typography/headings";
26+
import { SSOLoginForm } from "./login/SSOLoginForm";
2627

2728
function Item(props: { icon: string; iconSize?: string; text: string }) {
2829
const iconSize = props.iconSize || 28;
@@ -57,8 +58,6 @@ export function Login() {
5758
const [hostFromContext, setHostFromContext] = useState<string | undefined>();
5859
const [repoPathname, setRepoPathname] = useState<string | undefined>();
5960

60-
const showWelcome = !hasLoggedInBefore() && !hasVisitedMarketingWebsiteBefore() && !urlHash.startsWith("https://");
61-
6261
useEffect(() => {
6362
try {
6463
if (urlHash.length > 0) {
@@ -84,51 +83,59 @@ export function Login() {
8483
}
8584
}, [hostFromContext, authProviders]);
8685

87-
const authorizeSuccessful = async (payload?: string) => {
88-
updateUser().catch(console.error);
89-
90-
// Check for a valid returnTo in payload
91-
const safeReturnTo = getSafeURLRedirect(payload);
92-
if (safeReturnTo) {
93-
// ... and if it is, redirect to it
94-
window.location.replace(safeReturnTo);
95-
}
96-
};
86+
const showWelcome = !hasLoggedInBefore() && !hasVisitedMarketingWebsiteBefore() && !urlHash.startsWith("https://");
9787

98-
const updateUser = async () => {
88+
const updateUser = useCallback(async () => {
9989
await getGitpodService().reconnect();
10090
const [user] = await Promise.all([getGitpodService().server.getLoggedInUser()]);
10191
setUser(user);
10292
markLoggedIn();
103-
};
93+
}, [setUser]);
10494

105-
const openLogin = async (host: string) => {
106-
setErrorMessage(undefined);
95+
const authorizeSuccessful = useCallback(
96+
async (payload?: string) => {
97+
updateUser().catch(console.error);
10798

108-
try {
109-
await openAuthorizeWindow({
110-
login: true,
111-
host,
112-
onSuccess: authorizeSuccessful,
113-
onError: (payload) => {
114-
let errorMessage: string;
115-
if (typeof payload === "string") {
116-
errorMessage = payload;
117-
} else {
118-
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
119-
if (payload.error === "email_taken") {
120-
errorMessage = `Email address already used in another account. Please log in with ${
121-
(payload as any).host
122-
}.`;
99+
// Check for a valid returnTo in payload
100+
const safeReturnTo = getSafeURLRedirect(payload);
101+
if (safeReturnTo) {
102+
// ... and if it is, redirect to it
103+
window.location.replace(safeReturnTo);
104+
}
105+
},
106+
[updateUser],
107+
);
108+
109+
const openLogin = useCallback(
110+
async (host: string) => {
111+
setErrorMessage(undefined);
112+
113+
try {
114+
await openAuthorizeWindow({
115+
login: true,
116+
host,
117+
onSuccess: authorizeSuccessful,
118+
onError: (payload) => {
119+
let errorMessage: string;
120+
if (typeof payload === "string") {
121+
errorMessage = payload;
122+
} else {
123+
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
124+
if (payload.error === "email_taken") {
125+
errorMessage = `Email address already used in another account. Please log in with ${
126+
(payload as any).host
127+
}.`;
128+
}
123129
}
124-
}
125-
setErrorMessage(errorMessage);
126-
},
127-
});
128-
} catch (error) {
129-
console.log(error);
130-
}
131-
};
130+
setErrorMessage(errorMessage);
131+
},
132+
});
133+
} catch (error) {
134+
console.log(error);
135+
}
136+
},
137+
[authorizeSuccessful],
138+
);
132139

133140
return (
134141
<div id="login-container" className="z-50 flex w-screen h-screen">
@@ -197,7 +204,7 @@ export function Login() {
197204
)}
198205
</div>
199206

200-
<div className="flex flex-col space-y-3 items-center">
207+
<div className="w-56 mx-auto flex flex-col space-y-3 items-center">
201208
{providerFromContext ? (
202209
<button
203210
key={"button" + providerFromContext.host}
@@ -223,6 +230,8 @@ export function Login() {
223230
</button>
224231
))
225232
)}
233+
234+
<SSOLoginForm onSuccess={authorizeSuccessful} />
226235
</div>
227236
{errorMessage && <ErrorMessage imgSrc={exclamation} message={errorMessage} />}
228237
</div>
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Copyright (c) 2023 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 { FC, useCallback, useEffect, useState } from "react";
8+
import Alert from "../components/Alert";
9+
import { Button } from "../components/Button";
10+
import { TextInputField } from "../components/forms/TextInputField";
11+
import { useOnBlurError } from "../hooks/use-onblur-error";
12+
import { openOIDCStartWindow } from "../provider-utils";
13+
14+
type Props = {
15+
onSuccess: () => void;
16+
};
17+
export const SSOLoginForm: FC<Props> = ({ onSuccess }) => {
18+
const [orgSlug, setOrgSlug] = useState("");
19+
const [error, setError] = useState("");
20+
const [showSSO, setShowSSO] = useState<boolean>(false);
21+
22+
useEffect(() => {
23+
try {
24+
const content = window.localStorage.getItem("gitpod-ui-experiments");
25+
const object = content && JSON.parse(content);
26+
if (object["ssoLogin"] === true) {
27+
setShowSSO(true);
28+
}
29+
} catch {
30+
// ignore as non-critical
31+
}
32+
}, []);
33+
34+
const openLoginWithSSO = useCallback(
35+
async (e) => {
36+
e.preventDefault();
37+
38+
if (!orgSlug.trim()) {
39+
return;
40+
}
41+
42+
try {
43+
await openOIDCStartWindow({
44+
orgSlug,
45+
onSuccess: onSuccess,
46+
onError: (payload) => {
47+
let errorMessage: string;
48+
if (typeof payload === "string") {
49+
errorMessage = payload;
50+
} else {
51+
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
52+
}
53+
setError(errorMessage);
54+
},
55+
});
56+
} catch (error) {
57+
console.log(error);
58+
}
59+
},
60+
[onSuccess, orgSlug],
61+
);
62+
63+
const slugError = useOnBlurError(
64+
"Organization slug must not be longer than 63 characters.",
65+
orgSlug.trim().length <= 63,
66+
);
67+
68+
// Don't render anything if not enabled
69+
if (!showSSO) {
70+
return null;
71+
}
72+
73+
return (
74+
<form onSubmit={openLoginWithSSO}>
75+
<div className="mt-10 space-y-2">
76+
<TextInputField
77+
label="Organization Slug"
78+
placeholder="my-team"
79+
value={orgSlug}
80+
onChange={setOrgSlug}
81+
error={slugError.message}
82+
onBlur={slugError.onBlur}
83+
/>
84+
<Button className="w-full" type="secondary" disabled={!orgSlug.trim() || !slugError.isValid}>
85+
Continue with SSO
86+
</Button>
87+
{error && <Alert type="info">{error}</Alert>}
88+
</div>
89+
</form>
90+
);
91+
};

components/dashboard/src/provider-utils.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,21 @@ function simplifyProviderName(host: string) {
3737
}
3838
}
3939

40-
interface OpenAuthorizeWindowParams {
40+
interface WindowMessageHandler {
41+
onSuccess?: (payload?: string) => void;
42+
onError?: (error: string | { error: string; description?: string }) => void;
43+
}
44+
45+
interface OpenAuthorizeWindowParams extends WindowMessageHandler {
4146
login?: boolean;
4247
host: string;
4348
scopes?: string[];
4449
overrideScopes?: boolean;
4550
overrideReturn?: string;
46-
onSuccess?: (payload?: string) => void;
47-
onError?: (error: string | { error: string; description?: string }) => void;
4851
}
4952

5053
async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
51-
const { login, host, scopes, overrideScopes, onSuccess, onError } = params;
54+
const { login, host, scopes, overrideScopes } = params;
5255
let search = "message=success";
5356
const redirectURL = getSafeURLRedirect();
5457
if (redirectURL) {
@@ -72,6 +75,12 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
7275
})
7376
.toString();
7477

78+
openModalWindow(url);
79+
80+
attachMessageListener(params);
81+
}
82+
83+
function openModalWindow(url: string) {
7584
const width = 800;
7685
const height = 800;
7786
const left = window.screen.width / 2 - width / 2;
@@ -83,7 +92,9 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
8392
"gitpod-auth-window",
8493
`width=${width},height=${height},top=${top},left=${left},status=yes,scrollbars=yes,resizable=yes`,
8594
);
95+
}
8696

97+
function attachMessageListener({ onSuccess, onError }: WindowMessageHandler) {
8798
const eventListener = (event: MessageEvent) => {
8899
// todo: check event.origin
89100

@@ -117,6 +128,30 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
117128
};
118129
window.addEventListener("message", eventListener);
119130
}
131+
132+
interface OpenOIDCStartWindowParams extends WindowMessageHandler {
133+
orgSlug: string;
134+
}
135+
136+
async function openOIDCStartWindow(params: OpenOIDCStartWindowParams) {
137+
const { orgSlug } = params;
138+
let search = "message=success";
139+
const redirectURL = getSafeURLRedirect();
140+
if (redirectURL) {
141+
search = `${search}&returnTo=${encodeURIComponent(redirectURL)}`;
142+
}
143+
const returnTo = gitpodHostUrl.with({ pathname: "complete-auth", search }).toString();
144+
const url = gitpodHostUrl
145+
.with((url) => ({
146+
pathname: `/iam/oidc/start`,
147+
search: `orgSlug=${orgSlug}&returnTo=${encodeURIComponent(returnTo)}`,
148+
}))
149+
.toString();
150+
151+
openModalWindow(url);
152+
153+
attachMessageListener(params);
154+
}
120155
const getSafeURLRedirect = (source?: string) => {
121156
const returnToURL: string | null = new URLSearchParams(source ? source : window.location.search).get("returnTo");
122157
if (returnToURL) {
@@ -131,4 +166,4 @@ const getSafeURLRedirect = (source?: string) => {
131166
}
132167
};
133168

134-
export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow, getSafeURLRedirect };
169+
export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow, getSafeURLRedirect, openOIDCStartWindow };

components/gitpod-db/go/dbtest/oidc_client_config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func NewOIDCClientConfig(t *testing.T, record db.OIDCClientConfig) db.OIDCClient
3434
result.ID = record.ID
3535
}
3636

37-
if record.OrganizationID != nil {
37+
if record.OrganizationID != uuid.Nil {
3838
result.OrganizationID = record.OrganizationID
3939
}
4040

components/gitpod-db/go/oidc_client_config.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
type OIDCClientConfig struct {
1818
ID uuid.UUID `gorm:"primary_key;column:id;type:char;size:36;" json:"id"`
1919

20-
OrganizationID *uuid.UUID `gorm:"column:organizationId;type:char;size:36;" json:"organizationId"`
20+
OrganizationID uuid.UUID `gorm:"column:organizationId;type:char;size:36;" json:"organizationId"`
2121

2222
Issuer string `gorm:"column:issuer;type:char;size:255;" json:"issuer"`
2323

@@ -128,7 +128,7 @@ func ListOIDCClientConfigsForOrganization(ctx context.Context, conn *gorm.DB, or
128128

129129
tx := conn.
130130
WithContext(ctx).
131-
Where("organizationId = ?", organizationID).
131+
Where("organizationId = ?", organizationID.String()).
132132
Where("deleted = ?", 0).
133133
Order("id").
134134
Find(&results)
@@ -166,3 +166,26 @@ func DeleteOIDCClientConfig(ctx context.Context, conn *gorm.DB, id, organization
166166

167167
return nil
168168
}
169+
170+
func GetOIDCClientConfigByOrgSlug(ctx context.Context, conn *gorm.DB, slug string) (OIDCClientConfig, error) {
171+
var config OIDCClientConfig
172+
173+
if slug == "" {
174+
return OIDCClientConfig{}, fmt.Errorf("slug is a required argument")
175+
}
176+
177+
tx := conn.
178+
WithContext(ctx).
179+
Table((&OIDCClientConfig{}).TableName()).
180+
// TODO: is there a better way to reference table names here and below?
181+
Joins("JOIN d_b_team team ON team.id = d_b_oidc_client_config.organizationId").
182+
Where("team.slug = ?", slug).
183+
Where("d_b_oidc_client_config.deleted = ?", 0).
184+
First(&config)
185+
186+
if tx.Error != nil {
187+
return OIDCClientConfig{}, fmt.Errorf("failed to get oidc client config by org slug (slug: %s): %v", slug, tx.Error)
188+
}
189+
190+
return config, nil
191+
}

0 commit comments

Comments
 (0)