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.

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