Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

129 linesCodeBlameRaw
1import { Form, Link, data, redirect } from "react-router";
2
3import { type User, ssoAddresses } from "@g1t/contracts";
4
5import type { Route } from "./+types/auth-sso";
6import { AuthCard } from "../components/auth-card";
7import { SubmitButton } from "../components/ui";
8import { cookie } from "../lib/github";
9import { page } from "../lib/meta";
10import { safeNext } from "../lib/next";
11import { SSO_STATE_COOKIE, bindingCookie } from "../lib/sso";
12import { sso } from "../lib/sso.server";
13import { assertSameOrigin, nextPath, requireUser, sessionTokenOf } from "../lib/session.server";
14
15export function meta({ params, ...args }: Route.MetaArgs) {
16 return page(args, { title: `Single sign-on · ${params.owner} · g1t` });
17}
18
19/** Whether `viewer` belongs to `slug`, held out of it or not. */
20function belongs(viewer: User, slug: string): boolean {
21 return (viewer.workspaces ?? []).some((m) => m.slug === slug) || (viewer.held ?? []).some((hold) => hold.slug === slug);
22}
23
24/**
25 * Sets off to the workspace's identity provider: the state the provider
26 * sends back is kept in a short-lived cookie (OpenID Connect and OAuth 2.0);
27 * a SAML request's id binds its answer instead.
28 */
29async function start(request: Request, user: User, slug: string, next: string) {
30 const token = sessionTokenOf(request);
31 if (!token) return { slug, error: "Single sign-on is for people signed in on this site, not for access tokens." };
32 const summary = await sso.summary(slug);
33 if (!summary.enabled) throw data({ slug, error: `${slug} has not set up single sign-on.` }, { status: 404 });
34 const addresses = ssoAddresses(new URL(request.url).origin, summary.workspace_id, slug);
35 const started = await sso.start({
36 user,
37 session_token: token,
38 slug,
39 redirect_uri: summary.protocol === "saml" ? addresses.acsUrl : addresses.callbackUrl,
40 entity_id: addresses.entityId,
41 next,
42 });
43 if (!started.ok) return { slug, error: started.error.message };
44 const headers = new Headers();
45 if (started.value.state) headers.append("set-cookie", cookie(SSO_STATE_COOKIE, started.value.state, 600));
46 if (started.value.binding) headers.append("set-cookie", bindingCookie(started.value.binding));
47 throw redirect(started.value.url, { headers });
48}
49
50/**
51 * A member (held out or not) sets off at once. Anyone else is asked first,
52 * on this page, and sets off only by saying so: a link from another site
53 * must never make someone a member of a workspace without their knowing.
54 */
55export async function loader({ request, params, context }: Route.LoaderArgs) {
56 const user = requireUser(context, request);
57 const key = params.owner.toLowerCase();
58 const summary = await sso.summary(key);
59 // Named by its id (from a page the provider sent them back to) or an old
60 // name: to its name now, so pages and links say it.
61 if (summary.slug && summary.slug !== key) {
62 const url = new URL(request.url);
63 throw redirect(`/auth/sso/${summary.slug}${url.search}`);
64 }
65 const slug = summary.slug || key;
66 if (!summary.enabled) return data({ slug, error: `${slug} has not set up single sign-on.` }, { status: 404 });
67 if (belongs(user, slug)) return start(request, user, slug, nextPath(request));
68 return { slug, confirm: true as const, username: user.display_username ?? user.username, next: nextPath(request) };
69}
70
71export async function action({ request, params, context }: Route.ActionArgs) {
72 assertSameOrigin(request);
73 const user = requireUser(context, request);
74 const form = await request.formData();
75 return start(request, user, params.owner.toLowerCase(), safeNext(String(form.get("next") ?? "")));
76}
77
78export default function SsoStart({ loaderData, actionData }: Route.ComponentProps) {
79 const shown = actionData ?? loaderData;
80 if ("error" in shown && shown.error) return <SsoProblem slug={shown.slug} error={shown.error} />;
81 if (!("confirm" in loaderData)) return null;
82 return (
83 <AuthCard
84 title={`Join ${loaderData.slug}`}
85 subtitle="Through its identity provider"
86 footer={
87 <Link to="/" className="text-fg underline underline-offset-4">
88 Not now
89 </Link>
90 }
91 >
92 <p className="text-sm text-muted">
93 You are signed in as <span className="font-medium text-fg">{loaderData.username}</span>. Signing in through{" "}
94 {loaderData.slug}&apos;s identity provider links your account there to this one and, if {loaderData.slug} lets its
95 provider&apos;s people join, makes you a member.
96 </p>
97 <Form method="post" className="mt-6">
98 <input type="hidden" name="next" value={loaderData.next} />
99 <SubmitButton className="w-full" pending="Setting off…">
100 Continue to {loaderData.slug}&apos;s sign-in
101 </SubmitButton>
102 </Form>
103 </AuthCard>
104 );
105}
106
107/** What a single sign-on that did not work says, with the ways on. */
108export function SsoProblem({ slug, error }: { slug: string; error: string }) {
109 return (
110 <AuthCard
111 title="Single sign-on"
112 subtitle={`Signing in through ${slug}'s identity provider`}
113 footer={
114 <Link to="/" className="text-fg underline underline-offset-4">
115 Back to g1t
116 </Link>
117 }
118 >
119 <p className="text-sm text-danger" role="alert">
120 {error}
121 </p>
122 <p className="mt-6 text-sm">
123 <Link to={`/auth/sso/${slug}`} className="font-medium text-accent underline underline-offset-4">
124 Try again
125 </Link>
126 </p>
127 </AuthCard>
128 );
129}