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.

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