Skip to content

SSO Login UI & Flow #17052

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

Closed
wants to merge 8 commits into from
Closed
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
2 changes: 1 addition & 1 deletion components/BUILD.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ scripts:
srcs:
- components/**/*
script: |
if [ "$(git diff --cached | grep "+" | grep ".only")" != "" ]; then
if [ "$(git diff --cached | grep "+" | grep "\w+\.only(")" != "" ]; then
echo ""
echo "COMMIT FAILED:"
echo "Some spec files have .only. Please remove only and try committing again."
Expand Down
88 changes: 49 additions & 39 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 { useCallback, useContext, useEffect, useMemo, useState } 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 @@ -84,51 +85,57 @@ 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 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 @@ -224,6 +231,9 @@ export function Login() {
))
)}
</div>

<SSOLoginForm />

{errorMessage && <ErrorMessage imgSrc={exclamation} message={errorMessage} />}
</div>
</div>
Expand Down
64 changes: 64 additions & 0 deletions components/dashboard/src/login/SSOLoginForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* 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 { useMutation } from "@tanstack/react-query";
import { FC, useCallback, useState } from "react";
import Alert from "../components/Alert";
import { Button } from "../components/Button";
import { TextInputField } from "../components/forms/TextInputField";
import { oidcService } from "../service/public-api";

export const SSOLoginForm: FC = () => {
const [orgSlug, setOrgSlug] = useState("");
const [error, setError] = useState("");

// TODO: remove this
const [loginUrl, setLoginUrl] = useState("");

const exchangeSlug = useMutation({
mutationFn: async ({ slug }: { slug: string }) => {
// make api call to get provider id by slug
// return provider id
return await oidcService.getSSOLoginID({ slug: orgSlug });
},
});

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

// make api call to get provider id by slug
const resp = await exchangeSlug.mutateAsync({ slug: orgSlug });
const loginId = resp?.id;

// No SSO configured for provided slug
if (!loginId) {
setError("It looks like SSO has not been configured for that organization.");
}

// create sso login url with provider id
const loginUrl = `/oidc/start/?id=${loginId}`;
setLoginUrl(loginUrl);

// openAuthorize window for sso w/ login url
},
[exchangeSlug, orgSlug],
);

// TODO: Wrap with feature flag check
return (
<form onSubmit={handleSSOLogin}>
<div className="mt-10 space-y-2">
<TextInputField label="Organization Slug" value={orgSlug} onChange={setOrgSlug} />
<Button className="w-full" type="secondary" disabled={!orgSlug} loading={exchangeSlug.isLoading}>
Continue with SSO
</Button>
{loginUrl && <p>{loginUrl}</p>}
{error && <Alert type="info">{error}</Alert>}
</div>
</form>
);
};
22 changes: 22 additions & 0 deletions components/gitpod-db/go/oidc_client_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,25 @@ 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()).
Joins("JOIN d_b_team team ON team.id = d_b_oidc_client_config.organizationId").
Where("team.slug = ?", slug).
Where("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
}
16 changes: 16 additions & 0 deletions components/public-api-server/pkg/apiv1/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,22 @@ func (s *OIDCService) DeleteClientConfig(ctx context.Context, req *connect.Reque
return connect.NewResponse(&v1.DeleteClientConfigResponse{}), nil
}

func (s *OIDCService) GetSSOLoginID(ctx context.Context, req *connect.Request[v1.GetSSOLoginIDRequest]) (*connect.Response[v1.GetSSOLoginIDResponse], error) {
slug := req.Msg.GetSlug()
if slug == "" {
return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("slug must not be empty"))
}

config, err := db.GetOIDCClientConfigByOrgSlug(ctx, s.dbConn, slug)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to retrieve oidc client config by org slug"))
}

return connect.NewResponse(&v1.GetSSOLoginIDResponse{
Id: config.ID.String(),
}), nil
}

func (s *OIDCService) getConnection(ctx context.Context) (protocol.APIInterface, error) {
token, err := auth.TokenFromContext(ctx)
if err != nil {
Expand Down
10 changes: 10 additions & 0 deletions components/public-api/gitpod/experimental/v1/oidc.proto
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ service OIDCService {

// Removes a OIDC client configuration by ID.
rpc DeleteClientConfig(DeleteClientConfigRequest) returns (DeleteClientConfigResponse) {};

rpc GetSSOLoginID(GetSSOLoginIDRequest) returns (GetSSOLoginIDResponse) {};
}

message CreateClientConfigRequest {
Expand Down Expand Up @@ -184,3 +186,11 @@ message DeleteClientConfigRequest {
}

message DeleteClientConfigResponse {}

message GetSSOLoginIDRequest {
string slug = 1;
}

message GetSSOLoginIDResponse {
string id = 1;
}
Loading