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.

151 linesCodeBlameRaw
1/**
2 * Single sign-on: a workspace's own identity provider, over OpenID
3 * Connect, OAuth 2.0 or SAML 2.0, on every paid plan. Mirrors
4 * `crates/contracts/src/sso.rs`, which says how it works: it never signs
5 * anyone in to g1t; a person signed in to g1t proves themselves to the
6 * workspace's provider to use the workspace.
7 */
8import type { ServiceBinding } from "./clients";
9import type { User } from "./identity";
10import type { Result } from "./result";
11
12export type SsoProtocol = "oidc" | "oauth2" | "saml";
13
14/** What a connection needs, by protocol; it holds no secret. */
15export type SsoConfig = {
16 issuer?: string | null;
17 client_id?: string | null;
18 scopes?: string | null;
19 authorization_url?: string | null;
20 token_url?: string | null;
21 userinfo_url?: string | null;
22 subject_claim?: string | null;
23 email_claim?: string | null;
24 idp_entity_id?: string | null;
25 idp_sso_url?: string | null;
26 certificates?: string[];
27 /** Who may join through it: only addresses the provider vouches for at these domains. Empty: anyone. */
28 join_domains?: string[];
29};
30
31export type SsoConnection = {
32 id: string;
33 protocol: SsoProtocol;
34 config: SsoConfig;
35 has_client_secret: boolean;
36 /** SAML's signing certificates, by SHA-256 fingerprint. */
37 certificates: { fingerprint: string }[];
38 allow_join: boolean;
39 tested_at: string | null;
40 /** The username of whoever first signed in through it. */
41 tested_by: string | null;
42 created_at: string;
43 updated_at: string;
44};
45
46export type SsoIdentity = {
47 username: string;
48 display_username?: string | null;
49 subject: string;
50 email: string | null;
51 linked_at: string;
52 last_used_at: string;
53};
54
55export type SsoSettings = {
56 slug: string;
57 connection: SsoConnection | null;
58 required: boolean;
59 /** Whether the owner asking has signed in through it, which requiring it needs. */
60 you_linked: boolean;
61 identities: SsoIdentity[];
62 /** SCIM, when it is on: who the provider has assigned. */
63 scim?: ScimStatus | null;
64};
65
66export type ScimStatus = {
67 /** The token's last four characters. */
68 hint: string;
69 created_at: string;
70 created_by: string | null;
71 last_used_at: string | null;
72 users: ProvisionedUser[];
73};
74
75export type ProvisionedUser = {
76 id: string;
77 user_name: string;
78 display_name: string | null;
79 active: boolean;
80 /** The g1t account they signed in as, once they have. */
81 username: string | null;
82 updated_at: string;
83};
84
85export type SsoSummary = {
86 enabled: boolean;
87 protocol: SsoProtocol | null;
88 required: boolean;
89 allow_join: boolean;
90};
91
92export type SsoFinished = { slug: string; next: string; joined: boolean; first: boolean };
93
94export type SaveSso = {
95 protocol: SsoProtocol;
96 config: SsoConfig;
97 /** Empty keeps the one saved. */
98 client_secret?: string | null;
99 /** SAML certificates as pasted (PEM); empty keeps the ones saved. */
100 certificate?: string | null;
101 allow_join: boolean;
102};
103
104async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
105 const response = await service.fetch(`https://service/rpc/${method}`, {
106 method: "POST",
107 headers: { "content-type": "application/json" },
108 body: JSON.stringify(args),
109 });
110 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
111 return (await response.json()) as T;
112}
113
114/** Single sign-on: methods of the identity service. */
115export function ssoClient(identity: ServiceBinding) {
116 const call = <T>(method: string, args: object) => rpc<T>(identity, method, args);
117 return {
118 settings: (actor: User, slug: string) => call<Result<SsoSettings>>("sso_settings", { actor, slug }),
119 summary: (slug: string) => call<SsoSummary>("sso_summary", { slug }),
120 save: (actor: User, slug: string, input: SaveSso) =>
121 call<Result<SsoSettings>>("save_sso", { actor, slug, surface: "web", ...input }),
122 setRequired: (actor: User, slug: string, required: boolean) =>
123 call<Result<boolean>>("set_sso_required", { actor, slug, required, surface: "web" }),
124 remove: (actor: User, slug: string) => call<Result<boolean>>("delete_sso", { actor, slug, surface: "web" }),
125 unlink: (actor: User, slug: string, username: string) =>
126 call<Result<boolean>>("unlink_sso_identity", { actor, slug, username, surface: "web" }),
127 start: (input: { user: User; session_token: string; slug: string; redirect_uri: string; entity_id?: string | null; next: string }) =>
128 call<Result<{ url: string; state: string | null }>>("sso_start", input),
129 finish: (input: { slug: string; session_token: string; state: string; code: string }) =>
130 call<Result<SsoFinished>>("sso_finish", input),
131 saml: (slug: string, samlResponse: string) => call<Result<SsoFinished>>("sso_saml", { slug, saml_response: samlResponse }),
132 metadata: (entityId: string, acsUrl: string) => call<string>("sso_metadata", { entity_id: entityId, acs_url: acsUrl }),
133 enableScim: (actor: User, slug: string) => call<Result<{ token: string; hint: string }>>("enable_scim", { actor, slug, surface: "web" }),
134 disableScim: (actor: User, slug: string) => call<Result<boolean>>("disable_scim", { actor, slug, surface: "web" }),
135 };
136}
137
138/**
139 * Where a workspace's provider is pointed, from the site's origin: g1t's
140 * entity id (its metadata's address), the assertion consumer service, and
141 * the callback for OpenID Connect and OAuth 2.0.
142 */
143/** The SCIM 2.0 base URL for a workspace, on the API. */
144export function scimUrl(api: string, slug: string): string {
145 return `${api.replace(/\/+$/, "")}/scim/v2/${encodeURIComponent(slug.toLowerCase())}`;
146}
147
148export function ssoAddresses(origin: string, slug: string) {
149 const base = `${origin.replace(/\/+$/, "")}/auth/sso/${encodeURIComponent(slug.toLowerCase())}`;
150 return { entityId: `${base}/metadata`, metadataUrl: `${base}/metadata`, acsUrl: `${base}/saml`, callbackUrl: `${base}/callback` };
151}