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.

120 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 } 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, 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 = started.value.state ? { "set-cookie": cookie(SSO_STATE_COOKIE, started.value.state, 600) } : undefined;
45 throw redirect(started.value.url, { headers });
46}
47
48/**
49 * A member (held out or not) sets off at once. Anyone else is asked first,
50 * on this page, and sets off only by saying so: a link from another site
51 * must never make someone a member of a workspace without their knowing.
52 */
53export async function loader({ request, params, context }: Route.LoaderArgs) {
54 const user = requireUser(context, request);
55 const slug = params.owner.toLowerCase();
56 if (belongs(user, slug)) return start(request, user, slug, nextPath(request));
57 const summary = await sso.summary(slug);
58 if (!summary.enabled) return data({ slug, error: `${slug} has not set up single sign-on.` }, { status: 404 });
59 return { slug, confirm: true as const, username: user.display_username ?? user.username, next: nextPath(request) };
60}
61
62export async function action({ request, params, context }: Route.ActionArgs) {
63 assertSameOrigin(request);
64 const user = requireUser(context, request);
65 const form = await request.formData();
66 return start(request, user, params.owner.toLowerCase(), safeNext(String(form.get("next") ?? "")));
67}
68
69export default function SsoStart({ loaderData, actionData }: Route.ComponentProps) {
70 const shown = actionData ?? loaderData;
71 if ("error" in shown && shown.error) return <SsoProblem slug={shown.slug} error={shown.error} />;
72 if (!("confirm" in loaderData)) return null;
73 return (
74 <AuthCard
75 title={`Join ${loaderData.slug}`}
76 subtitle="Through its identity provider"
77 footer={
78 <Link to="/" className="text-fg underline underline-offset-4">
79 Not now
80 </Link>
81 }
82 >
83 <p className="text-sm text-muted">
84 You are signed in as <span className="font-medium text-fg">{loaderData.username}</span>. Signing in through{" "}
85 {loaderData.slug}&apos;s identity provider links your account there to this one and, if {loaderData.slug} lets its
86 provider&apos;s people join, makes you a member.
87 </p>
88 <Form method="post" className="mt-6">
89 <input type="hidden" name="next" value={loaderData.next} />
90 <SubmitButton className="w-full" pending="Setting off…">
91 Continue to {loaderData.slug}&apos;s sign-in
92 </SubmitButton>
93 </Form>
94 </AuthCard>
95 );
96}
97
98/** What a single sign-on that did not work says, with the ways on. */
99export function SsoProblem({ slug, error }: { slug: string; error: string }) {
100 return (
101 <AuthCard
102 title="Single sign-on"
103 subtitle={`Signing in through ${slug}'s identity provider`}
104 footer={
105 <Link to="/" className="text-fg underline underline-offset-4">
106 Back to g1t
107 </Link>
108 }
109 >
110 <p className="text-sm text-danger" role="alert">
111 {error}
112 </p>
113 <p className="mt-6 text-sm">
114 <Link to={`/auth/sso/${slug}`} className="font-medium text-accent underline underline-offset-4">
115 Try again
116 </Link>
117 </p>
118 </AuthCard>
119 );
120}