| 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 | }; |
| 28 | |
| 29 | export 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 | |
| 44 | export 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 | |
| 53 | export 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 | }; |
| 61 | |
| 62 | export type SsoSummary = { |
| 63 | enabled: boolean; |
| 64 | protocol: SsoProtocol | null; |
| 65 | required: boolean; |
| 66 | allow_join: boolean; |
| 67 | }; |
| 68 | |
| 69 | export type SsoFinished = { slug: string; next: string; joined: boolean; first: boolean }; |
| 70 | |
| 71 | export type SaveSso = { |
| 72 | protocol: SsoProtocol; |
| 73 | config: SsoConfig; |
| 74 | /** Empty keeps the one saved. */ |
| 75 | client_secret?: string | null; |
| 76 | /** SAML certificates as pasted (PEM); empty keeps the ones saved. */ |
| 77 | certificate?: string | null; |
| 78 | allow_join: boolean; |
| 79 | }; |
| 80 | |
| 81 | async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> { |
| 82 | const response = await service.fetch(`https://service/rpc/${method}`, { |
| 83 | method: "POST", |
| 84 | headers: { "content-type": "application/json" }, |
| 85 | body: JSON.stringify(args), |
| 86 | }); |
| 87 | if (!response.ok) throw new Error(`${method} failed with status ${response.status}`); |
| 88 | return (await response.json()) as T; |
| 89 | } |
| 90 | |
| 91 | /** Single sign-on: methods of the identity service. */ |
| 92 | export function ssoClient(identity: ServiceBinding) { |
| 93 | const call = <T>(method: string, args: object) => rpc<T>(identity, method, args); |
| 94 | return { |
| 95 | settings: (actor: User, slug: string) => call<Result<SsoSettings>>("sso_settings", { actor, slug }), |
| 96 | summary: (slug: string) => call<SsoSummary>("sso_summary", { slug }), |
| 97 | save: (actor: User, slug: string, input: SaveSso) => |
| 98 | call<Result<SsoSettings>>("save_sso", { actor, slug, surface: "web", ...input }), |
| 99 | setRequired: (actor: User, slug: string, required: boolean) => |
| 100 | call<Result<boolean>>("set_sso_required", { actor, slug, required, surface: "web" }), |
| 101 | remove: (actor: User, slug: string) => call<Result<boolean>>("delete_sso", { actor, slug, surface: "web" }), |
| 102 | unlink: (actor: User, slug: string, username: string) => |
| 103 | call<Result<boolean>>("unlink_sso_identity", { actor, slug, username, surface: "web" }), |
| 104 | start: (input: { user: User; session_token: string; slug: string; redirect_uri: string; entity_id?: string | null; next: string }) => |
| 105 | call<Result<{ url: string; state: string | null }>>("sso_start", input), |
| 106 | finish: (input: { slug: string; session_token: string; state: string; code: string }) => |
| 107 | call<Result<SsoFinished>>("sso_finish", input), |
| 108 | saml: (slug: string, samlResponse: string) => call<Result<SsoFinished>>("sso_saml", { slug, saml_response: samlResponse }), |
| 109 | metadata: (entityId: string, acsUrl: string) => call<string>("sso_metadata", { entity_id: entityId, acs_url: acsUrl }), |
| 110 | }; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Where a workspace's provider is pointed, from the site's origin: g1t's |
| 115 | * entity id (its metadata's address), the assertion consumer service, and |
| 116 | * the callback for OpenID Connect and OAuth 2.0. |
| 117 | */ |
| 118 | export function ssoAddresses(origin: string, slug: string) { |
| 119 | const base = `${origin.replace(/\/+$/, "")}/auth/sso/${encodeURIComponent(slug.toLowerCase())}`; |
| 120 | return { entityId: `${base}/metadata`, metadataUrl: `${base}/metadata`, acsUrl: `${base}/saml`, callbackUrl: `${base}/callback` }; |
| 121 | } |