Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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;
}