Skip to content

Sign in with SSO #17055

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

Merged
merged 13 commits into from
Mar 29, 2023
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
91 changes: 50 additions & 41 deletions components/dashboard/src/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

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

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

const showWelcome = !hasLoggedInBefore() && !hasVisitedMarketingWebsiteBefore() && !urlHash.startsWith("https://");

useEffect(() => {
try {
if (urlHash.length > 0) {
Expand All @@ -84,51 +83,59 @@ export function Login() {
}
}, [hostFromContext, authProviders]);

const authorizeSuccessful = async (payload?: string) => {
updateUser().catch(console.error);

// 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 showWelcome = !hasLoggedInBefore() && !hasVisitedMarketingWebsiteBefore() && !urlHash.startsWith("https://");

const updateUser = async () => {
const updateUser = useCallback(async () => {
await getGitpodService().reconnect();
const [user] = await Promise.all([getGitpodService().server.getLoggedInUser()]);
setUser(user);
markLoggedIn();
};
}, [setUser]);

const openLogin = async (host: string) => {
setErrorMessage(undefined);
const authorizeSuccessful = useCallback(
async (payload?: string) => {
updateUser().catch(console.error);

try {
await openAuthorizeWindow({
login: true,
host,
onSuccess: authorizeSuccessful,
onError: (payload) => {
let errorMessage: string;
if (typeof payload === "string") {
errorMessage = payload;
} else {
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
if (payload.error === "email_taken") {
errorMessage = `Email address already used in another account. Please log in with ${
(payload as any).host
}.`;
// Check for a valid returnTo in payload
const safeReturnTo = getSafeURLRedirect(payload);
if (safeReturnTo) {
// ... and if it is, redirect to it
window.location.replace(safeReturnTo);
}
},
[updateUser],
);

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

try {
await openAuthorizeWindow({
login: true,
host,
onSuccess: authorizeSuccessful,
onError: (payload) => {
let errorMessage: string;
if (typeof payload === "string") {
errorMessage = payload;
} else {
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
if (payload.error === "email_taken") {
errorMessage = `Email address already used in another account. Please log in with ${
(payload as any).host
}.`;
}
}
}
setErrorMessage(errorMessage);
},
});
} catch (error) {
console.log(error);
}
};
setErrorMessage(errorMessage);
},
});
} catch (error) {
console.log(error);
}
},
[authorizeSuccessful],
);

return (
<div id="login-container" className="z-50 flex w-screen h-screen">
Expand Down Expand Up @@ -197,7 +204,7 @@ export function Login() {
)}
</div>

<div className="flex flex-col space-y-3 items-center">
<div className="w-56 mx-auto flex flex-col space-y-3 items-center">
{providerFromContext ? (
<button
key={"button" + providerFromContext.host}
Expand All @@ -223,6 +230,8 @@ export function Login() {
</button>
))
)}

<SSOLoginForm onSuccess={authorizeSuccessful} />
</div>
{errorMessage && <ErrorMessage imgSrc={exclamation} message={errorMessage} />}
</div>
Expand Down
91 changes: 91 additions & 0 deletions components/dashboard/src/login/SSOLoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* Copyright (c) 2023 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 { FC, useCallback, useEffect, useState } from "react";
import Alert from "../components/Alert";
import { Button } from "../components/Button";
import { TextInputField } from "../components/forms/TextInputField";
import { useOnBlurError } from "../hooks/use-onblur-error";
import { openOIDCStartWindow } from "../provider-utils";

type Props = {
onSuccess: () => void;
};
export const SSOLoginForm: FC<Props> = ({ onSuccess }) => {
const [orgSlug, setOrgSlug] = useState("");
const [error, setError] = useState("");
const [showSSO, setShowSSO] = useState<boolean>(false);

useEffect(() => {
try {
const content = window.localStorage.getItem("gitpod-ui-experiments");
const object = content && JSON.parse(content);
if (object["ssoLogin"] === true) {
setShowSSO(true);
}
} catch {
// ignore as non-critical
}
}, []);

const openLoginWithSSO = useCallback(
async (e) => {
e.preventDefault();

if (!orgSlug.trim()) {
return;
}

try {
await openOIDCStartWindow({
orgSlug,
onSuccess: onSuccess,
onError: (payload) => {
let errorMessage: string;
if (typeof payload === "string") {
errorMessage = payload;
} else {
errorMessage = payload.description ? payload.description : `Error: ${payload.error}`;
}
setError(errorMessage);
},
});
} catch (error) {
console.log(error);
}
},
[onSuccess, orgSlug],
);

const slugError = useOnBlurError(
"Organization slug must not be longer than 63 characters.",
orgSlug.trim().length <= 63,
);

// Don't render anything if not enabled
if (!showSSO) {
return null;
}

return (
<form onSubmit={openLoginWithSSO}>
<div className="mt-10 space-y-2">
<TextInputField
label="Organization Slug"
placeholder="my-team"
value={orgSlug}
onChange={setOrgSlug}
error={slugError.message}
onBlur={slugError.onBlur}
/>
<Button className="w-full" type="secondary" disabled={!orgSlug.trim() || !slugError.isValid}>
Continue with SSO
</Button>
{error && <Alert type="info">{error}</Alert>}
</div>
</form>
);
};
45 changes: 40 additions & 5 deletions components/dashboard/src/provider-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,21 @@ function simplifyProviderName(host: string) {
}
}

interface OpenAuthorizeWindowParams {
interface WindowMessageHandler {
onSuccess?: (payload?: string) => void;
onError?: (error: string | { error: string; description?: string }) => void;
}

interface OpenAuthorizeWindowParams extends WindowMessageHandler {
login?: boolean;
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 { login, host, scopes, overrideScopes } = params;
let search = "message=success";
const redirectURL = getSafeURLRedirect();
if (redirectURL) {
Expand All @@ -72,6 +75,12 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
})
.toString();

openModalWindow(url);

attachMessageListener(params);
}

function openModalWindow(url: string) {
const width = 800;
const height = 800;
const left = window.screen.width / 2 - width / 2;
Expand All @@ -83,7 +92,9 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
"gitpod-auth-window",
`width=${width},height=${height},top=${top},left=${left},status=yes,scrollbars=yes,resizable=yes`,
);
}

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

Expand Down Expand Up @@ -117,6 +128,30 @@ async function openAuthorizeWindow(params: OpenAuthorizeWindowParams) {
};
window.addEventListener("message", eventListener);
}

interface OpenOIDCStartWindowParams extends WindowMessageHandler {
orgSlug: string;
}

async function openOIDCStartWindow(params: OpenOIDCStartWindowParams) {
const { orgSlug } = params;
let search = "message=success";
const redirectURL = getSafeURLRedirect();
if (redirectURL) {
search = `${search}&returnTo=${encodeURIComponent(redirectURL)}`;
}
const returnTo = gitpodHostUrl.with({ pathname: "complete-auth", search }).toString();
const url = gitpodHostUrl
.with((url) => ({
pathname: `/iam/oidc/start`,
search: `orgSlug=${orgSlug}&returnTo=${encodeURIComponent(returnTo)}`,
}))
.toString();

openModalWindow(url);

attachMessageListener(params);
}
const getSafeURLRedirect = (source?: string) => {
const returnToURL: string | null = new URLSearchParams(source ? source : window.location.search).get("returnTo");
if (returnToURL) {
Expand All @@ -131,4 +166,4 @@ const getSafeURLRedirect = (source?: string) => {
}
};

export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow, getSafeURLRedirect };
export { iconForAuthProvider, simplifyProviderName, openAuthorizeWindow, getSafeURLRedirect, openOIDCStartWindow };
2 changes: 1 addition & 1 deletion components/gitpod-db/go/dbtest/oidc_client_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func NewOIDCClientConfig(t *testing.T, record db.OIDCClientConfig) db.OIDCClient
result.ID = record.ID
}

if record.OrganizationID != nil {
if record.OrganizationID != uuid.Nil {
result.OrganizationID = record.OrganizationID
}

Expand Down
27 changes: 25 additions & 2 deletions components/gitpod-db/go/oidc_client_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (
type OIDCClientConfig struct {
ID uuid.UUID `gorm:"primary_key;column:id;type:char;size:36;" json:"id"`

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

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

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

tx := conn.
WithContext(ctx).
Where("organizationId = ?", organizationID).
Where("organizationId = ?", organizationID.String()).
Where("deleted = ?", 0).
Order("id").
Find(&results)
Expand Down Expand Up @@ -166,3 +166,26 @@ func DeleteOIDCClientConfig(ctx context.Context, conn *gorm.DB, id, organization

return nil
}

func GetOIDCClientConfigByOrgSlug(ctx context.Context, conn *gorm.DB, slug string) (OIDCClientConfig, error) {
var config OIDCClientConfig

if slug == "" {
return OIDCClientConfig{}, fmt.Errorf("slug is a required argument")
}

tx := conn.
WithContext(ctx).
Table((&OIDCClientConfig{}).TableName()).
// TODO: is there a better way to reference table names here and below?
Joins("JOIN d_b_team team ON team.id = d_b_oidc_client_config.organizationId").
Where("team.slug = ?", slug).
Where("d_b_oidc_client_config.deleted = ?", 0).
First(&config)

if tx.Error != nil {
return OIDCClientConfig{}, fmt.Errorf("failed to get oidc client config by org slug (slug: %s): %v", slug, tx.Error)
}

return config, nil
}
Loading