| 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 | */ |
| 8 | import type { ServiceBinding } from "./clients"; |
| 9 | import type { User } from "./identity"; |
| 10 | import type { Result } from "./result"; |
| 11 | |
| 12 | export type SsoProtocol = "oidc" | "oauth2" | "saml"; |
| 13 | |
| 14 | /** What a connection needs, by protocol; it holds no secret. */ |
| 15 | export 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 | |
| 31 | export 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 | |
| 46 | export 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 | |
| 55 | export 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 | |
| 68 | export 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 | |
| 77 | export 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 | |
| 87 | export 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 | |
| 97 | export type SsoFinished = { slug: string; next: string; joined: boolean; first: boolean }; |
| 98 | |
| 99 | export 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 | |
| 109 | async 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. */ |
| 120 | export 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. */ |
| 150 | export function scimUrl(api: string, workspaceId: string): string { |
| 151 | return `${api.replace(/\/+$/, "")}/scim/v2/${encodeURIComponent(workspaceId)}`; |
| 152 | } |
| 153 | |
| 154 | export 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 | } |