crates/gunnar-ui-server/proto/gunnar/v1/sso.proto
syntax = "proto3";
package gunnar.v1;
import "gunnar/v1/common.proto";
// **Company single sign-on** (owner, 2026-10-05): an account's own identity
// provider — OIDC or SAML 2.0 — so a company's people log in with the
// company's identity system; SCIM 2.0 provisioning and a CSV import beside it.
//
// The account model does not move. An account's admin configures the IdP; a
// person the IdP vouches for is an EMPLOYEE of THAT account: created just in
// time by the first login (a NEW user — an existing principal is never
// attached, and never across accounts), or provisioned beforehand by the
// directory and claimed by its user name. gunnar decides every identity: the
// console relays what the browser and the IdP said, and gunnar verifies it,
// maps it and hands back an identity-core session (IDENTITY-HOST: the same
// opaque session a passkey login mints, `nornir.identity.v1.IdentityService`).
//
// Secrets: the OIDC client secret is write-only (sealed in gunnar's secret
// store, never answered, never logged); a SCIM bearer is shown once and kept
// only as its hash. The operator never sees either.
//
// AUTHORISATION. The provider, the bearers, the import and the directory are
// the ACCOUNT ADMIN's (Admin over the account's namespace — the rule
// `Accounts.EnrollEmployee` applies). Discover, Begin, Finish, SecondFactor
// and Scim are the RELAY's (a server admin: the console's own credential).
service Sso {
rpc SetProvider(SetProviderRequest) returns (SetProviderResponse);
rpc GetProvider(GetProviderRequest) returns (GetProviderResponse);
rpc RemoveProvider(RemoveProviderRequest) returns (RemoveProviderResponse);
// A SCIM bearer for the account's directory: the secret once, its hash kept.
rpc CreateScimToken(CreateScimTokenRequest) returns (CreateScimTokenResponse);
rpc ListScimTokens(ListScimTokensRequest) returns (ListScimTokensResponse);
rpc RevokeScimToken(RevokeScimTokenRequest) returns (RevokeScimTokenResponse);
// The account's directory: who SSO, SCIM and the import made, and whether
// each may log in.
rpc ListDirectory(ListDirectoryRequest) returns (ListDirectoryResponse);
// Deactivate (gated, web sessions revoked, data kept) or reactivate one.
rpc SetUserActive(SetUserActiveRequest) returns (SetUserActiveResponse);
// **The CSV import** for an account without SCIM: `user_name` (required),
// `display_name`, `email`, `active` — new rows become NEW employees, known
// ones are updated, `active=false` deactivates. A background job (A34-17):
// the answer is the job as it stands; the whole file is applied in ONE
// write or not at all.
rpc ImportUsers(ImportUsersRequest) returns (ImportUsersResponse);
// Where the account's import job is (the console polls it).
rpc ImportStatus(ImportStatusRequest) returns (ImportUsersResponse);
// The login page's "Sign in with <company>": an account name, an email
// address or a domain → the account whose IdP it leads to.
rpc Discover(DiscoverRequest) returns (DiscoverResponse);
// The redirect to the IdP: OIDC (code + PKCE S256 + state + nonce) or SAML
// (an AuthnRequest, HTTP-Redirect). Single use, ten minutes.
rpc Begin(BeginRequest) returns (BeginResponse);
// What came back: verified by gunnar, mapped to an employee of the account
// the flow began for, and the session key enrolled — or, when the account
// requires a passkey too, a pending ticket for `SecondFactor`.
rpc Finish(FinishRequest) returns (FinishResponse);
// The passkey after the IdP: an assertion of a passkey the SAME person
// holds, or — on the first login, when they hold none — the registration of
// their first.
rpc SecondFactor(SecondFactorRequest) returns (SecondFactorResponse);
// One SCIM 2.0 request, relayed byte for byte; the bearer decides the
// account.
rpc Scim(ScimRequest) returns (ScimResponse);
// **Prove an e-mail domain** (A34-5): the account admin's, after publishing
// the TXT record its `ProviderView.domain_proofs` names. Only a proven domain
// routes a sign-in (Discover), counts for a claim or a JIT employee, and is
// held against other accounts.
rpc VerifyDomain(VerifyDomainRequest) returns (VerifyDomainResponse);
// **Open a rebind** (A34-11, an IdP migration): the named employees (all the
// bound ones when none is named) are unbound from their old IdP subject, and
// each one's next sign-in binds it afresh, on the claim rules.
rpc RebindUsers(RebindUsersRequest) returns (RebindUsersResponse);
}
// **Who may become a new employee just in time** (A34-25).
enum SsoJit {
// Anyone the IdP signs in (as before).
SSO_JIT_OPEN = 0;
// A verified e-mail in one of the account's proven domains.
SSO_JIT_DOMAINS = 1;
// Nobody: SCIM or the CSV import provisions every employee.
SSO_JIT_OFF = 2;
}
// A domain's DNS proof (A34-5): publish TXT `txt_name` = `txt_value`.
message DomainProof {
string domain = 1;
string txt_name = 2;
string txt_value = 3;
bool verified = 4;
}
enum SsoProtocol {
SSO_PROTOCOL_UNSPECIFIED = 0;
SSO_PROTOCOL_OIDC = 1;
SSO_PROTOCOL_SAML = 2;
}
message OidcSettings {
// Exactly as its discovery document names it (https).
string issuer = 1;
string client_id = 2;
// Beside `openid` (always asked). Empty: `email profile`.
repeated string scopes = 3;
// The claim naming the person in the company's directory. Empty:
// `preferred_username`, then `email`.
string username_claim = 4;
// Empty: `name`.
string display_name_claim = 5;
// INPUT: the CA certificate (PEM; or a chain up to one) the IdP's https
// certificate is issued under, for a company IdP behind its own CA. gunnar
// trusts it for THIS account's IdP alone — its discovery document, keys and
// token endpoint — beside the public roots, and for nothing else. At most
// 64 KiB and 10 certificates, each a CA. Empty keeps the stored one
// (`SetProviderRequest.clear_idp_ca` removes it). In answers: empty.
string idp_ca_pem = 6;
// ANSWERS only: the SHA-256 fingerprints of the stored CA certificates.
repeated string idp_ca_fingerprints = 7;
}
message SamlSettings {
// INPUT: the IdP's metadata, pasted — or its https URL, read by gunnar.
// Either fills the three fields below; set by hand they win.
string idp_metadata_xml = 1;
string idp_metadata_url = 2;
string idp_entity_id = 3;
string idp_sso_url = 4;
// INPUT: PEM or base64 DER. In answers: the SHA-256 fingerprints.
repeated string idp_certificates = 5;
// Empty: the NameID.
string username_attribute = 6;
string display_name_attribute = 7;
}
message SsoProvider {
// The account (its principal id and namespace).
string account = 1;
SsoProtocol protocol = 2;
// "Sign in with <display_name>". Empty: the account.
string display_name = 3;
// Email domains that lead the login page here; one domain, one account.
repeated string domains = 4;
OidcSettings oidc = 5;
SamlSettings saml = 6;
// The account's EMPLOYEES log in through the IdP only: a passkey alone is
// refused for them. The account admin is not an employee and keeps the
// passkey (the break-glass).
bool require_sso = 7;
// After the IdP, a passkey of the same person.
bool require_passkey = 8;
// The console the redirect URI and the SAML ACS hang off.
string console_origin = 9;
// ANSWERS only: whether a client secret is stored (never the secret).
bool has_client_secret = 10;
int64 updated_at_unix_ms = 11;
string updated_by = 12;
// ANSWERS only: the `domains` whose DNS proof was read (A34-5).
repeated string verified_domains = 13;
// Who may become a new employee just in time (A34-25).
SsoJit jit = 14;
}
message ProviderView {
SsoProvider provider = 1;
// What the admin registers at the IdP: the OIDC redirect URI, or the SAML
// SP entity id + ACS URL (and the SP metadata to upload).
string redirect_uri = 2;
string sp_entity_id = 3;
string acs_url = 4;
string sp_metadata_xml = 5;
// Each named domain's TXT record and whether it is proven (A34-5).
repeated DomainProof domain_proofs = 6;
}
message VerifyDomainRequest {
string account = 1;
string domain = 2;
}
message VerifyDomainResponse { ProviderView view = 1; }
message RebindUsersRequest {
string account = 1;
// Empty: every bound employee of the account (an issuer migration).
repeated string principal_ids = 2;
// With exactly one principal: its id at the NEW identity provider (its
// `externalId`), so its next sign-in claims on the admin's binding.
string external_id = 3;
}
message RebindUsersResponse { repeated DirectoryUser users = 1; }
message SetProviderRequest {
SsoProvider provider = 1;
// WRITE-ONLY. Empty keeps the stored one.
string client_secret = 2;
bool clear_client_secret = 3;
// Remove the stored IdP CA (`OidcSettings.idp_ca_pem`).
bool clear_idp_ca = 4;
}
message SetProviderResponse { ProviderView view = 1; }
message GetProviderRequest {
string account = 1;
// The console's own origin (`https://console.example`): with it the answer
// carries the addresses the admin registers at the IdP, provider or not
// (A34-47: the console renders them and builds none).
string console_origin = 2;
}
message GetProviderResponse {
// Unset: the account has none.
ProviderView view = 1;
// Unset without a `console_origin`.
ConsoleAddresses addresses = 2;
}
// What the account admin registers at its IdP and hands its people, for one
// console origin; every path segment percent-encoded by gunnar.
message ConsoleAddresses {
string redirect_uri = 1;
string sp_entity_id = 2;
string acs_url = 3;
// The sign-in link for the account's people.
string login_url = 4;
string scim_base_url = 5;
}
message RemoveProviderRequest { string account = 1; }
message RemoveProviderResponse { bool removed = 1; }
message ScimToken {
string token_id = 1;
string account = 2;
string label = 3;
int64 created_at_unix_ms = 4;
string created_by = 5;
int64 last_used_unix_ms = 6;
// 0: never expires (A34-27).
int64 expires_at_unix_ms = 7;
// It may only read the directory.
bool read_only = 8;
// A warning for the admin's list (unused for 30 days, expiring, expired);
// empty when there is none.
string attention = 9;
}
message CreateScimTokenRequest {
string account = 1;
string label = 2;
// 0: never expires; at most 366.
uint32 expires_in_days = 3;
bool read_only = 4;
}
message CreateScimTokenResponse {
ScimToken token = 1;
// Shown here once and never again; gunnar keeps only its hash.
string secret = 2;
}
message ListScimTokensRequest { string account = 1; }
message ListScimTokensResponse { repeated ScimToken tokens = 1; }
message RevokeScimTokenRequest {
string account = 1;
string token_id = 2;
}
message RevokeScimTokenResponse { bool revoked = 1; }
message DirectoryUser {
string principal_id = 1;
string user_name = 2;
string display_name = 3;
string email = 4;
bool active = 5;
// `jit`, `scim` or `csv`.
string source = 6;
// Bound to an IdP subject (it has logged in).
bool bound = 7;
int64 created_at_unix_ms = 8;
int64 last_login_unix_ms = 9;
string external_id = 10;
// A rebind is open (A34-11): the next sign-in binds it afresh.
bool rebind = 11;
}
message ListDirectoryRequest { string account = 1; }
message ListDirectoryResponse {
repeated DirectoryUser users = 1;
uint32 groups = 2;
}
message SetUserActiveRequest {
string account = 1;
string principal_id = 2;
bool active = 3;
}
message SetUserActiveResponse { DirectoryUser user = 1; }
message ImportUsersRequest {
string account = 1;
string csv = 2;
// Ask, write nothing.
bool dry_run = 3;
}
message ImportUsersResponse {
uint32 created = 1;
uint32 updated = 2;
uint32 deactivated = 3;
uint32 reactivated = 4;
uint32 unchanged = 5;
// One sentence per row that was not applied.
repeated string errors = 6;
bool dry_run = 7;
// The job (A34-17): `queued`, `running`, `done` or `failed`; the counts are
// final once `done`. A failed import applied nothing; `error` says why.
string job_id = 8;
string state = 9;
string error = 10;
uint64 done_rows = 11;
uint64 total_rows = 12;
// The principals whose web sessions the import ENDED (each deactivated
// person, A34-9): the console ends its own sessions of them, as for SCIM.
repeated string ended_sessions = 13;
}
message ImportStatusRequest {
string account = 1;
// Empty: the account's latest job.
string job_id = 2;
}
message DiscoverRequest {
// An account name, an email address or a domain.
string hint = 1;
}
message DiscoverResponse {
bool found = 1;
string account = 2;
string display_name = 3;
SsoProtocol protocol = 4;
// Whether ANY account on this Gunnar has an identity provider configured
// (round 5.3, owner 2026-10-08: the login page's company field on an estate
// with no IdP read as "what's this?"). Answered for every hint, the empty
// one included: the console asks with no hint and offers the field only
// when this is true. An existence bit; it names no account.
bool any_provider = 5;
}
message BeginRequest {
string account = 1;
// Passed to the IdP as `login_hint` (OIDC).
string login_hint = 2;
// The console's origin; must be the provider's.
string console_origin = 3;
// The browser's address as the relay saw it (A34-6): begins, open flows and
// failures are counted per (account, client), so a few addresses cannot lock
// an account out. Empty: one client.
string client = 4;
}
message BeginResponse {
string redirect_url = 1;
// The flow's single-use state (OIDC `state`, SAML `RelayState`). The
// console binds it to the browser (a cookie) and hands it back.
string state = 2;
int64 expires_at_unix_ms = 3;
SsoProtocol protocol = 4;
}
message FinishRequest {
string state = 1;
// OIDC: the authorization code.
string code = 2;
// SAML: the POSTed `SAMLResponse`, base64.
string saml_response = 3;
string console_origin = 4;
// Were the session key to enrol and its expiry; the session is the core's.
reserved 5, 6;
// OIDC: the `error` the IdP put on the callback instead of a code.
string idp_error = 7;
// The browser's address as the relay saw it (A34-6); empty: the client that began.
string client = 8;
}
enum SecondFactorNeed {
SECOND_FACTOR_NEED_NONE = 0;
// An assertion of one of `credential_ids`.
SECOND_FACTOR_NEED_PASSKEY = 1;
// The person holds no passkey yet: register the first.
SECOND_FACTOR_NEED_PASSKEY_REGISTRATION = 2;
}
message FinishResponse {
Principal principal = 1;
string account = 2;
// This login created the employee (just in time).
bool created = 3;
SecondFactorNeed second_factor = 4;
// The ticket `SecondFactor` redeems: single use, five minutes.
string pending = 5;
repeated bytes credential_ids = 6;
// Were the enrolled session key and its expiry.
reserved 7, 8;
// **The identity-core session** (an opaque token), when no second factor
// is owed: the console carries it as the person's cookie.
string session = 9;
// PASSKEY_REGISTRATION: the core's link for the person's first passkey
// (`IdentityService.RedeemStart`); its session then finishes `SecondFactor`.
string enrolment = 10;
}
// **The passkey after the IdP**: the console logged the person in with their
// passkey through the identity core (PASSKEY), or redeemed `enrolment`
// (PASSKEY_REGISTRATION), and hands the core's session here with the ticket:
// it must be the SAME person the identity provider vouched for.
message SecondFactorRequest {
string pending = 1;
// Were the assertion or the registration, relayed; the core verifies them.
reserved 2 to 11;
// The identity-core session the passkey minted.
string session = 12;
}
message SecondFactorResponse {
Principal principal = 1;
// Were the session key's fingerprint and expiry and the passkey's label.
reserved 2, 3, 5;
bool user_verified = 4;
}
message ScimRequest {
// The `Authorization: Bearer` secret, as the IdP sent it.
string bearer = 1;
string method = 2;
// Below `/scim/v2`: `/Users`, `/Users/<id>`, `/Groups`, …
string path = 3;
// The raw query string (filter, startIndex, count).
string query = 4;
bytes body = 5;
// The public base the resources' `meta.location` hangs off.
string base_url = 6;
// The IdP's address as the console saw it: refused bearers are counted per
// client (A34-27).
string client = 7;
}
message ScimResponse {
uint32 status = 1;
bytes body = 2;
// `application/scim+json`.
string content_type = 3;
string location = 4;
// The principals whose web sessions this request ENDED at gunnar (a user
// deactivated or deleted): the console ends its own sessions of them, so
// their browser is signed out rather than left holding a key gunnar no
// longer takes.
repeated string ended_sessions = 5;
}