use std::sync::Arc;
use gunnar_proto::sso_server::Sso;
use gunnar_proto::{
BeginRequest, BeginResponse, CreateScimTokenRequest, CreateScimTokenResponse, DirectoryUser,
DiscoverRequest, DiscoverResponse, FinishRequest, FinishResponse, GetProviderRequest,
GetProviderResponse, GrantLevel, ImportStatusRequest, ImportUsersRequest, ImportUsersResponse,
ListDirectoryRequest, ListDirectoryResponse, ListScimTokensRequest, ListScimTokensResponse,
ProviderView, RebindUsersRequest, RebindUsersResponse, RemoveProviderRequest,
RemoveProviderResponse, RevokeScimTokenRequest, RevokeScimTokenResponse, ScimRequest,
ScimResponse, SecondFactorNeed, SecondFactorRequest, SecondFactorResponse, SetProviderRequest,
SetProviderResponse, SetUserActiveRequest, SetUserActiveResponse, SsoProtocol, SsoProvider,
VerifyDomainRequest, VerifyDomainResponse,
};
use tonic::{Request, Response, Status};
use crate::auth::{Access as Scope, AuditRecord, Caller, AUDIT_TARGET};
use crate::service::SsoService;
use crate::sso::directory::{normalize_domain, normalize_user_name};
use crate::sso::plane::{Room, SecretChange, SsoLoginAsk, SsoPlane, UserPatch};
use crate::sso::{oidc, saml, scim, Second, SCIM_PER_MINUTE, SECOND_TTL_MS};
fn now_ms() -> i64 {
crate::bus::now_unix_ms()
}
fn account_of(raw: &str) -> Result<String, Status> {
let account = raw.trim();
if account.is_empty() || account.len() > 128 || account.contains('/') {
return Err(Status::invalid_argument(
"`account` is required (an account name)",
));
}
Ok(account.to_owned())
}
fn origin_ok(origin: &str, loopback: bool) -> bool {
let o = origin.trim().trim_end_matches('/');
let rest = if let Some(r) = o.strip_prefix("https://") {
r
} else if loopback {
match o.strip_prefix("http://") {
Some(r) => r,
None => return false,
}
} else {
return false;
};
!rest.is_empty() && !rest.contains(['/', '?', '#', '@', ' '])
}
fn same_origin(a: &str, b: &str) -> bool {
a.trim()
.trim_end_matches('/')
.eq_ignore_ascii_case(b.trim().trim_end_matches('/'))
}
fn protocol_of(p: &SsoProvider) -> SsoProtocol {
SsoProtocol::try_from(p.protocol).unwrap_or(SsoProtocol::Unspecified)
}
fn hash(secret: &str) -> String {
crate::webauthn::sha256_hex(secret.as_bytes())
}
fn tokens(list: &[String], what: &str, most: usize) -> Result<Vec<String>, Status> {
let mut out: Vec<String> = Vec::new();
for raw in list {
let t = raw.trim();
if t.is_empty() {
continue;
}
if t.len() > 256
|| !t
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"_:./-".contains(&b))
{
return Err(Status::invalid_argument(format!(
"{what} {t:?} is not a plain name"
)));
}
if !out.iter().any(|o| o == t) {
out.push(t.to_owned());
}
}
if out.len() > most {
return Err(Status::invalid_argument(format!("at most {most} {what}s")));
}
Ok(out)
}
fn idp_ca_for(
stored: Option<&SsoProvider>,
pasted: &str,
clear: bool,
) -> Result<(Vec<Vec<u8>>, String), Status> {
let stored_ca = stored
.and_then(|p| p.oidc.as_ref())
.map(oidc::ca_of)
.unwrap_or_default();
let (ca, change) = match (clear, pasted.trim()) {
(true, _) => (Vec::new(), "cleared"),
(false, "") => (stored_ca, "kept"),
(false, pem) => (
crate::sso::idp_ca::parse_idp_ca(pem, now_ms())
.map_err(|why| Status::invalid_argument(format!("the IdP CA: {why}")))?,
"set",
),
};
let said = format!(
" idp_ca={} ({change})",
if ca.is_empty() {
"none".to_owned()
} else {
ca.iter()
.map(|d| saml::fingerprint(d))
.collect::<Vec<_>>()
.join(",")
}
);
Ok((ca, said))
}
fn or_stored(field: &str, stored: Option<&str>) -> String {
match field.trim() {
"" => stored.unwrap_or_default().to_owned(),
sent => sent.to_owned(),
}
}
fn claim_name(raw: &str, what: &str) -> Result<String, Status> {
let t = raw.trim();
if t.len() > 256 || t.chars().any(char::is_control) {
return Err(Status::invalid_argument(format!(
"{what} is not a claim or attribute name"
)));
}
Ok(t.to_owned())
}
impl SsoService {
fn plane(&self) -> Result<&dyn SsoPlane, Status> {
self.ctx.control.sso().ok_or_else(|| {
Status::unimplemented(
"single sign-on needs an identity table; this control plane has none",
)
})
}
fn admin_stepped_up<T>(
&self,
request: &Request<T>,
account: impl FnOnce(&T) -> &str,
method: &'static str,
) -> Result<(Caller, String), Status> {
let (caller, account) = self.admin(request, account, method)?;
if !crate::auth::stepped_up(request) {
self.audit(
method,
&caller.principal_id,
&account,
false,
"sso.step_up_refused",
"[stepup] no fresh user-verified touch",
);
return Err(Status::permission_denied(format!(
"[stepup] {method} needs a fresh touch of your own passkey (user-verified, within the last \
10 minutes)"
)));
}
Ok((caller, account))
}
fn admin<T>(
&self,
request: &Request<T>,
account: impl FnOnce(&T) -> &str,
method: &'static str,
) -> Result<(Caller, String), Status> {
let caller = self.ctx.caller(request)?;
let account = account_of(account(request.get_ref()))?;
self.ctx
.require(
&caller,
Scope::namespace(&account),
GrantLevel::Admin,
method,
)
.map_err(|status| {
crate::service::repos::account_admin_only_for(
status,
&account,
"the identity provider and directory of account",
)
})?;
if let Some(clans) = self.ctx.control.clans() {
if clans.clan_get(&account)?.is_some() {
return Err(Status::failed_precondition(format!(
"{account} is a clan, not an account: a clan has no identity provider, \
SCIM bearer or directory — its members are accounts of their own, and \
each account's admin configures its own"
)));
}
}
Ok((caller, account))
}
fn relay<T>(&self, request: &Request<T>, method: &'static str) -> Result<Caller, Status> {
self.ctx
.authorize(request, Scope::Server, GrantLevel::Admin, method)
}
fn audit(
&self,
method: &'static str,
principal: &str,
account: &str,
allowed: bool,
event: &'static str,
reason: &str,
) {
let scope = format!("account:{account}");
if let Some(ledger) = self.ctx.audit.as_deref() {
ledger.record(AuditRecord {
method,
principal,
fingerprint: "",
scope: &scope,
level: "",
auth_mode: self.ctx.config.auth.as_str(),
decision: if allowed { "allow" } else { "deny" },
reason,
});
}
if allowed {
tracing::info!(target: AUDIT_TARGET, event, method, principal, account, reason, "single sign-on");
} else {
tracing::warn!(target: AUDIT_TARGET, event, method, principal, account, reason, "single sign-on refused");
}
if !account.is_empty() {
let (kind, target) = match event {
"sso.login" | "sso.login_refused" => ("login", "sso"),
"sso.second_factor" | "sso.second_factor_refused" => ("login.second_factor", "sso"),
"sso.user_deactivated" => ("member.deactivated", "sso"),
"sso.user_reactivated" => ("member.reactivated", "sso"),
"sso.scim" | "sso.scim_refused" => ("member.scim", "scim"),
"sso.csv_import" => ("member.imported", "csv"),
other => (other, "sso"),
};
self.ctx.account_event(
Some(account.to_owned()),
crate::enterprise::AccountAuditEvent {
kind: kind.to_owned(),
actor: principal.to_owned(),
actor_machine: false,
target: target.to_owned(),
detail: reason.to_owned(),
refused: !allowed,
},
);
}
}
fn room(&self, account: &str) -> Result<Room, Status> {
let (cap, stored) = match crate::service::accounts::account_room(&self.ctx, account)? {
Some((cap, stored)) => (Some(cap), stored),
None => (None, 0),
};
Ok(Room {
room_bytes: crate::service::accounts::ROOM_PER_USER_BYTES,
cap,
stored,
})
}
fn view(&self, provider: SsoProvider) -> ProviderView {
let mut shown = provider.clone();
if let Some(s) = shown.saml.as_mut() {
s.idp_certificates = saml::certificates(s)
.unwrap_or_default()
.iter()
.map(|der| saml::fingerprint(der))
.collect();
s.idp_metadata_xml.clear();
}
if let Some(o) = shown.oidc.as_mut() {
o.idp_ca_fingerprints = oidc::ca_of(o)
.iter()
.map(|d| saml::fingerprint(d))
.collect();
o.idp_ca_pem.clear();
}
let origin = provider.console_origin.clone();
let is_saml = protocol_of(&provider) == SsoProtocol::Saml;
ProviderView {
redirect_uri: oidc::gunnar_core_paths::redirect_uri(&origin),
sp_entity_id: oidc::gunnar_core_paths::sp_entity_id(&origin, &provider.account),
acs_url: oidc::gunnar_core_paths::acs_url(&origin),
sp_metadata_xml: if is_saml {
saml::sp_metadata(&provider)
} else {
String::new()
},
domain_proofs: provider
.domains
.iter()
.map(|d| gunnar_proto::DomainProof {
domain: d.clone(),
txt_name: crate::sso::domains::txt_name(d),
txt_value: crate::sso::domains::proof_value(&provider.account, d),
verified: provider.verified_domains.contains(d),
})
.collect(),
provider: Some(shown),
}
}
fn directory_user(user: crate::sso::plane::DirUser) -> DirectoryUser {
user.user
}
}
pub const NO_RESOLVER: &str = "feed sso_dns_resolver: no DNS-over-HTTPS resolver is named for domain proofs, and none is dialled by default (the operator names the one this box may ask)";
#[tonic::async_trait]
impl Sso for SsoService {
async fn set_provider(
&self,
request: Request<SetProviderRequest>,
) -> Result<Response<SetProviderResponse>, Status> {
const METHOD: &str = "Sso.SetProvider";
let (caller, account) = self.admin_stepped_up(
&request,
|r: &SetProviderRequest| r.provider.as_ref().map_or("", |p| p.account.as_str()),
METHOD,
)?;
let input = request
.get_ref()
.provider
.clone()
.ok_or_else(|| Status::invalid_argument("`provider` is required"))?;
let plane = self.plane()?;
let request = request.into_inner();
let loopback = self.ctx.sso.allow_loopback_http();
let protocol = protocol_of(&input);
if !origin_ok(&input.console_origin, loopback) {
return Err(Status::invalid_argument(
"`console_origin` is the console's https origin (https://host[:port])",
));
}
let display_name = match input.display_name.trim() {
"" => account.clone(),
d if d.chars().count() <= 80 && !d.chars().any(char::is_control) => d.to_owned(),
_ => {
return Err(Status::invalid_argument(
"`display_name` is at most 80 printable characters",
))
}
};
let mut domains = Vec::new();
for raw in &input.domains {
if raw.trim().is_empty() {
continue;
}
let d = normalize_domain(raw).ok_or_else(|| {
Status::invalid_argument(format!("{raw:?} is not an email domain"))
})?;
crate::sso::domains::admissible(&d)
.map_err(|r| Status::invalid_argument(r.message()))?;
if !domains.contains(&d) {
domains.push(d);
}
}
if domains.len() > 20 {
return Err(Status::invalid_argument("at most 20 domains"));
}
let stored = plane.provider(&account)?;
let mut provider = SsoProvider {
account: account.clone(),
protocol: protocol as i32,
display_name,
domains,
oidc: None,
saml: None,
require_sso: input.require_sso,
require_passkey: input.require_passkey,
console_origin: input.console_origin.trim().trim_end_matches('/').to_owned(),
has_client_secret: false,
updated_at_unix_ms: now_ms(),
updated_by: caller.principal_id.clone(),
verified_domains: Vec::new(),
jit: input.jit,
};
let secret;
let ca_said;
match protocol {
SsoProtocol::Oidc => {
let o = input
.oidc
.clone()
.ok_or_else(|| Status::invalid_argument("an OIDC provider needs `oidc`"))?;
let issuer = o.issuer.trim().trim_end_matches('/').to_owned();
crate::sso::url_allowed_in(
&oidc::discovery_url(&issuer),
loopback,
&self.ctx.sso.private_nets(),
)
.map_err(|why| Status::invalid_argument(format!("the issuer: {why}")))?;
let (ca, said) = idp_ca_for(stored.as_ref(), &o.idp_ca_pem, request.clear_idp_ca)?;
ca_said = said;
let stored_oidc = stored.as_ref().and_then(|p| p.oidc.as_ref());
let client_id = o.client_id.trim().to_owned();
if client_id.is_empty() || client_id.len() > 512 {
return Err(Status::invalid_argument("the client id is required"));
}
provider.oidc = Some(gunnar_proto::OidcSettings {
issuer: issuer.clone(),
client_id,
scopes: tokens(&o.scopes, "scope", 20)?,
username_claim: claim_name(&o.username_claim, "the user-name claim")?,
display_name_claim: claim_name(
&or_stored(
&o.display_name_claim,
stored_oidc.map(|s| s.display_name_claim.as_str()),
),
"the display-name claim",
)?,
idp_ca_pem: crate::sso::idp_ca::idp_ca_pem(&ca),
idp_ca_fingerprints: Vec::new(),
});
secret = match (request.clear_client_secret, request.client_secret.trim()) {
(true, _) => SecretChange::Clear,
(false, "") => SecretChange::Keep,
(false, s) if s.len() > 4096 => {
return Err(Status::invalid_argument(
"the client secret is longer than 4 KiB",
))
}
(false, s) => SecretChange::Set(s.to_owned()),
};
if let Ok(egress) = self.ctx.sso.egress() {
oidc::idp(&self.ctx.sso, egress, &issuer, &ca, now_ms(), true)
.await
.map_err(|why| {
Status::failed_precondition(format!(
"the issuer could not be read: {why}"
))
})?;
}
}
SsoProtocol::Saml => {
let s = input
.saml
.clone()
.ok_or_else(|| Status::invalid_argument("a SAML provider needs `saml`"))?;
let mut s = s;
let stored_saml = stored.as_ref().and_then(|p| p.saml.as_ref());
let no_metadata =
s.idp_metadata_xml.trim().is_empty() && s.idp_metadata_url.trim().is_empty();
if no_metadata {
s.idp_entity_id = or_stored(
&s.idp_entity_id,
stored_saml.map(|p| p.idp_entity_id.as_str()),
);
s.idp_sso_url =
or_stored(&s.idp_sso_url, stored_saml.map(|p| p.idp_sso_url.as_str()));
}
let keep_certs =
s.idp_certificates.iter().all(|c| c.starts_with("SHA256:")) && no_metadata;
if keep_certs {
s.idp_certificates = stored_saml.map(|p| {
p.idp_certificates.iter().map(|b64| format!("-----BEGIN CERTIFICATE-----\n{b64}\n-----END CERTIFICATE-----")).collect()
}).unwrap_or_default();
}
let pasted = input.oidc.as_ref().map_or("", |o| o.idp_ca_pem.as_str());
let (ca, said) = idp_ca_for(stored.as_ref(), pasted, request.clear_idp_ca)?;
ca_said = said;
let egress = self
.ctx
.sso
.egress()
.ok()
.map(|e| saml::trusting(e, ca.clone()));
let mut resolved = saml::resolve_input(&self.ctx.sso, egress, &s)
.await
.map_err(Status::invalid_argument)?;
resolved.display_name_attribute = or_stored(
&resolved.display_name_attribute,
stored_saml.map(|p| p.display_name_attribute.as_str()),
);
provider.saml = Some(resolved);
provider.oidc = (!ca.is_empty()).then(|| gunnar_proto::OidcSettings {
idp_ca_pem: crate::sso::idp_ca::idp_ca_pem(&ca),
..Default::default()
});
secret = SecretChange::Clear;
}
SsoProtocol::Unspecified => {
return Err(Status::invalid_argument("`protocol` is OIDC or SAML"))
}
}
let saved = plane
.set_provider(provider, secret, &caller.principal_id)
.map_err(|err| {
self.audit(
METHOD,
&caller.principal_id,
&account,
false,
"sso.provider_refused",
&err.to_string(),
);
Status::from(err)
})?;
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
"sso.provider_set",
&format!(
"protocol={} require_sso={} require_passkey={} domains={}{ca_said}",
if protocol == SsoProtocol::Oidc {
"oidc"
} else {
"saml"
},
saved.require_sso,
saved.require_passkey,
saved.domains.join(",")
),
);
Ok(Response::new(SetProviderResponse {
view: Some(self.view(saved)),
}))
}
async fn get_provider(
&self,
request: Request<GetProviderRequest>,
) -> Result<Response<GetProviderResponse>, Status> {
let (_, account) = self.admin(&request, |r| r.account.as_str(), "Sso.GetProvider")?;
let view = self.plane()?.provider(&account)?.map(|p| self.view(p));
let origin = request
.get_ref()
.console_origin
.trim()
.trim_end_matches('/')
.to_owned();
let addresses = if origin.is_empty() {
None
} else if origin_ok(&origin, self.ctx.sso.allow_loopback_http()) {
use oidc::gunnar_core_paths as paths;
Some(gunnar_proto::ConsoleAddresses {
redirect_uri: paths::redirect_uri(&origin),
sp_entity_id: paths::sp_entity_id(&origin, &account),
acs_url: paths::acs_url(&origin),
login_url: paths::login_url(&origin, &account),
scim_base_url: paths::scim_base_url(&origin),
})
} else {
return Err(Status::invalid_argument(format!(
"`console_origin` {origin:?} is not an https origin"
)));
};
Ok(Response::new(GetProviderResponse { view, addresses }))
}
async fn remove_provider(
&self,
request: Request<RemoveProviderRequest>,
) -> Result<Response<RemoveProviderResponse>, Status> {
const METHOD: &str = "Sso.RemoveProvider";
let (caller, account) = self.admin_stepped_up(&request, |r| r.account.as_str(), METHOD)?;
let removed = self.plane()?.remove_provider(&account)?;
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
"sso.provider_removed",
if removed { "removed" } else { "there was none" },
);
Ok(Response::new(RemoveProviderResponse { removed }))
}
async fn create_scim_token(
&self,
request: Request<CreateScimTokenRequest>,
) -> Result<Response<CreateScimTokenResponse>, Status> {
const METHOD: &str = "Sso.CreateScimToken";
let (caller, account) = self.admin_stepped_up(&request, |r| r.account.as_str(), METHOD)?;
let label = request
.get_ref()
.label
.trim()
.chars()
.take(128)
.collect::<String>();
let expires = scim::bearer::expiry_of(request.get_ref().expires_in_days, now_ms())
.map_err(Status::invalid_argument)?;
let read_only = request.get_ref().read_only;
let secret = format!(
"gscim_{}",
crate::webauthn::b64url(&crate::webauthn::random_bytes(32))
);
let id = crate::webauthn::hex(&crate::webauthn::random_bytes(8));
let token = self.plane()?.create_scim_token(
&account,
&id,
&label,
&hash(&secret),
&caller.principal_id,
expires,
read_only,
)?;
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
"sso.scim_token_created",
&format!(
"token={id} scope={} expires={}",
if read_only { "read" } else { "read-write" },
if expires == 0 {
"never".to_owned()
} else {
crate::sso::rfc3339(expires / 1000)
}
),
);
Ok(Response::new(CreateScimTokenResponse {
token: Some(token),
secret,
}))
}
async fn list_scim_tokens(
&self,
request: Request<ListScimTokensRequest>,
) -> Result<Response<ListScimTokensResponse>, Status> {
let (_, account) = self.admin(&request, |r| r.account.as_str(), "Sso.ListScimTokens")?;
let now = now_ms();
let tokens = self
.plane()?
.scim_tokens(&account)?
.into_iter()
.map(|mut t| {
t.attention = scim::bearer::needs_attention(
t.created_at_unix_ms,
t.last_used_unix_ms,
t.expires_at_unix_ms,
now,
)
.unwrap_or_default()
.to_owned();
t
})
.collect();
Ok(Response::new(ListScimTokensResponse { tokens }))
}
async fn revoke_scim_token(
&self,
request: Request<RevokeScimTokenRequest>,
) -> Result<Response<RevokeScimTokenResponse>, Status> {
const METHOD: &str = "Sso.RevokeScimToken";
let (caller, account) = self.admin(&request, |r| r.account.as_str(), METHOD)?;
let id = request.get_ref().token_id.trim().to_owned();
let revoked = self.plane()?.revoke_scim_token(&account, &id)?;
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
"sso.scim_token_revoked",
&format!("token={id} revoked={revoked}"),
);
Ok(Response::new(RevokeScimTokenResponse { revoked }))
}
async fn list_directory(
&self,
request: Request<ListDirectoryRequest>,
) -> Result<Response<ListDirectoryResponse>, Status> {
let (_, account) = self.admin(&request, |r| r.account.as_str(), "Sso.ListDirectory")?;
let plane = self.plane()?;
let users = plane
.directory(&account)?
.into_iter()
.filter(|u| !u.deleted)
.map(Self::directory_user)
.collect();
let groups = plane.groups(&account)?.len() as u32;
Ok(Response::new(ListDirectoryResponse { users, groups }))
}
async fn set_user_active(
&self,
request: Request<SetUserActiveRequest>,
) -> Result<Response<SetUserActiveResponse>, Status> {
const METHOD: &str = "Sso.SetUserActive";
let (caller, account) = self.admin(&request, |r| r.account.as_str(), METHOD)?;
let request = request.into_inner();
let (user, changed) = self.plane()?.update_user(
&account,
request.principal_id.trim(),
UserPatch {
active: Some(request.active),
..UserPatch::default()
},
&caller.principal_id,
)?;
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
if request.active {
"sso.user_reactivated"
} else {
"sso.user_deactivated"
},
&format!("employee={} changed={changed}", user.user.principal_id),
);
Ok(Response::new(SetUserActiveResponse {
user: Some(Self::directory_user(user)),
}))
}
async fn import_users(
&self,
request: Request<ImportUsersRequest>,
) -> Result<Response<ImportUsersResponse>, Status> {
const METHOD: &str = "Sso.ImportUsers";
let (caller, account) = self.admin_stepped_up(&request, |r| r.account.as_str(), METHOD)?;
let request = request.into_inner();
if request.csv.len() > crate::sso::directory::CSV_MAX_BYTES {
return Err(Status::invalid_argument(crate::sso::directory::too_large()));
}
self.plane()?;
let room = self.room(&account)?;
let audit = {
let me = self.clone();
let principal = caller.principal_id.clone();
let account = account.clone();
let dry_run = request.dry_run;
Arc::new(move |done: &Result<scim::Imported, String>| {
if dry_run {
return;
}
match done {
Ok(done) => me.audit(
METHOD,
&principal,
&account,
true,
"sso.csv_import",
&format!(
"created={} updated={} deactivated={} reactivated={} errors={}",
done.created,
done.updated,
done.deactivated,
done.reactivated,
done.errors.len()
),
),
Err(why) => {
me.audit(METHOD, &principal, &account, false, "sso.csv_import", why)
}
}
})
};
let job = self.ctx.control.sso_import(scim::ImportOrder {
account: account.clone(),
csv: Arc::from(request.csv.as_str()),
dry_run: request.dry_run,
room,
actor: format!("csv:{}", caller.principal_id),
plane: Arc::downgrade(&self.ctx.control),
finished: audit,
})?;
Ok(Response::new(import_answer(job, request.dry_run)))
}
async fn import_status(
&self,
request: Request<ImportStatusRequest>,
) -> Result<Response<ImportUsersResponse>, Status> {
let (_, account) = self.admin(&request, |r| r.account.as_str(), "Sso.ImportStatus")?;
let wanted = request.get_ref().job_id.trim().to_owned();
match self.ctx.control.sso_import_status(&account)? {
Some(job) if wanted.is_empty() || job.job_id == wanted => {
let dry_run = job.outcome.dry_run;
Ok(Response::new(import_answer(job, dry_run)))
}
_ => Err(Status::not_found(format!(
"no import job {wanted} for account {account} (a finished job is kept ten minutes)"
))),
}
}
async fn discover(
&self,
request: Request<DiscoverRequest>,
) -> Result<Response<DiscoverResponse>, Status> {
self.relay(&request, "Sso.Discover")?;
let plane = self.plane()?;
let any_provider = plane.any_provider()?;
let hint = request.get_ref().hint.trim().to_ascii_lowercase();
if hint.is_empty() || hint.len() > 320 {
return Ok(Response::new(DiscoverResponse {
any_provider,
..Default::default()
}));
}
let domain = hint.rsplit_once('@').map_or(hint.as_str(), |(_, d)| d);
let account = match normalize_domain(domain) {
Some(d) => plane.account_for_domain(&d)?,
None => None,
};
let found = match account {
Some(a) if !a.contains('/') => plane.provider(&a)?,
_ => None,
};
Ok(Response::new(match found {
Some(p) => DiscoverResponse {
found: true,
account: p.account.clone(),
display_name: p.display_name.clone(),
protocol: p.protocol,
any_provider,
},
None => DiscoverResponse {
any_provider,
..Default::default()
},
}))
}
async fn begin(
&self,
request: Request<BeginRequest>,
) -> Result<Response<BeginResponse>, Status> {
const METHOD: &str = "Sso.Begin";
self.relay(&request, METHOD)?;
let request = request.into_inner();
let account = account_of(&request.account)?;
let plane = self.plane()?;
let now = now_ms();
let client = nornir_identity_sso::client_key(&request.client);
if !self.ctx.sso.allow_begin(&account, &client, now) {
self.audit(
METHOD,
"",
&account,
false,
"sso.rate_limited",
"too many sign-ins begun this minute",
);
return Err(Status::resource_exhausted(format!(
"[sso-busy] too many sign-ins to {account} from this address this minute; try again shortly"
)));
}
let provider = plane
.provider(&account)?
.ok_or_else(|| Status::not_found(format!("{account} has no single sign-on")))?;
if !same_origin(&request.console_origin, &provider.console_origin) {
return Err(Status::failed_precondition(format!(
"{account}'s single sign-on is configured for {}, not for this console",
provider.console_origin
)));
}
let (url, state, flow) = match protocol_of(&provider) {
SsoProtocol::Oidc => {
let egress = self.ctx.sso.egress().map_err(Status::unavailable)?;
oidc::begin(
&self.ctx.sso,
egress,
&provider,
request.login_hint.trim(),
now,
)
.await
.map_err(|why| {
Status::unavailable(format!("{account}'s identity provider: {why}"))
})?
}
SsoProtocol::Saml => {
saml::begin(&provider, now).map_err(Status::failed_precondition)?
}
SsoProtocol::Unspecified => {
return Err(Status::failed_precondition(
"the provider names no protocol",
))
}
};
let expires = flow.expires_ms;
let flow = crate::sso::Flow { client, ..flow };
self.ctx
.sso
.begin(hash(&state), flow, now)
.map_err(Status::resource_exhausted)?;
tracing::info!(target: AUDIT_TARGET, event = "sso.begin", account = %account, "a single sign-on began");
Ok(Response::new(BeginResponse {
redirect_url: url,
state,
expires_at_unix_ms: expires,
protocol: provider.protocol,
}))
}
async fn finish(
&self,
request: Request<FinishRequest>,
) -> Result<Response<FinishResponse>, Status> {
const METHOD: &str = "Sso.Finish";
self.relay(&request, METHOD)?;
let request = request.into_inner();
let now = now_ms();
let flow = self
.ctx
.sso
.take_flow(&hash(request.state.trim()), now)
.ok_or_else(|| {
Status::permission_denied(nornir_identity_sso::Refusal::Expired.message())
})?;
let account = flow.account.clone();
let client = match request.client.trim() {
"" => flow.client.clone(),
named => nornir_identity_sso::client_key(named),
};
let refuse = |why: String| {
let why = match nornir_identity_sso::code_of(&why) {
Some(_) => why,
None => nornir_identity_sso::Refusal::IdpRefused.with(&why),
};
self.ctx.sso.note_failure(&account, &client, now);
self.audit(METHOD, "", &account, false, "sso.login_refused", &why);
Status::permission_denied(format!("single sign-on refused: {why}"))
};
if self.ctx.sso.failures_held(&account, &client, now) {
self.audit(
METHOD,
"",
&account,
false,
"sso.rate_limited",
"too many failed sign-ins this minute",
);
return Err(Status::resource_exhausted(
"[sso-busy] too many failed sign-ins to this account from this address this minute; try again shortly",
));
}
if !same_origin(&request.console_origin, &flow.console_origin) {
return Err(refuse(nornir_identity_sso::Refusal::OtherConsole.message()));
}
if !request.idp_error.trim().is_empty() {
let said: String = request.idp_error.trim().chars().take(100).collect();
return Err(refuse(format!("the identity provider said {said:?}")));
}
let identity = self.ctx.identity.clone().ok_or_else(|| {
Status::unavailable("this server serves no identity core, so it signs nobody in")
})?;
let plane = self.plane()?;
let provider = match plane.provider(&account)? {
Some(p) if same_origin(&p.console_origin, &flow.console_origin) => p,
_ => {
return Err(refuse(
nornir_identity_sso::Refusal::ProviderChanged.message(),
))
}
};
let protocol = protocol_of(&provider);
let (issuer, subject, user_name, display_name, email, email_verified, idp_ids) =
match (protocol, flow.saml) {
(SsoProtocol::Oidc, false) => {
let egress = self.ctx.sso.egress().map_err(Status::unavailable)?;
let secret = plane.client_secret(&account)?.unwrap_or_default();
let v = oidc::finish(
&self.ctx.sso,
egress,
&provider,
&flow,
request.code.trim(),
&secret,
now,
)
.await
.map_err(refuse)?;
(
v.issuer,
v.subject,
v.user_name,
v.display_name,
v.email,
v.email_verified,
v.stable_ids,
)
}
(SsoProtocol::Saml, true) => {
let v = saml::finish(
&self.ctx.sso,
&provider,
&flow,
request.saml_response.trim(),
now,
)
.map_err(refuse)?;
let ids = vec![v.subject.clone()];
(
v.issuer,
v.subject,
v.user_name,
v.display_name,
v.email,
false,
ids,
)
}
_ => {
return Err(refuse(
nornir_identity_sso::Refusal::ProviderChanged.message(),
))
}
};
let user_name = normalize_user_name(&user_name)
.ok_or_else(|| refuse(nornir_identity_sso::Refusal::NamedNobody.message()))?;
let room = self.room(&account)?;
let outcome = plane
.login(SsoLoginAsk {
account: account.clone(),
issuer,
subject,
user_name: user_name.clone(),
display_name,
email,
email_verified,
idp_ids,
room,
session: None,
now_unix_ms: now,
})
.map_err(|err| match err {
crate::seam::ControlError::FailedPrecondition(m) => {
self.audit(METHOD, "", &account, false, "sso.login_refused", &m);
Status::failed_precondition(m)
}
other => refuse(other.to_string()),
})?;
let principal_id = outcome.principal.id.clone();
let mut answer = FinishResponse {
principal: Some(outcome.principal.clone()),
account: account.clone(),
created: outcome.created,
..FinishResponse::default()
};
if provider.require_passkey {
let passkeys = plane.passkeys_of(&principal_id)?;
let register = passkeys.is_empty();
let ticket = crate::webauthn::b64url(&crate::webauthn::random_bytes(32));
self.ctx
.sso
.park_second(
hash(&ticket),
Second {
account: account.clone(),
principal_id: principal_id.clone(),
register,
session_openssh: String::new(),
session_expires_ms: 0,
expires_ms: now + SECOND_TTL_MS,
},
now,
)
.map_err(Status::resource_exhausted)?;
if register {
let link = identity
.enrolment(
&principal_id,
&format!("sso:{account}"),
Some(SECOND_TTL_MS / 1000),
)
.map_err(crate::identity::status_of)?;
answer.enrolment = link.secret;
}
answer.second_factor = if register {
SecondFactorNeed::PasskeyRegistration
} else {
SecondFactorNeed::Passkey
} as i32;
answer.pending = ticket;
answer.credential_ids = passkeys;
} else {
let session = crate::identity::vouched_session(&identity, &principal_id, false)
.map_err(crate::identity::status_of)?;
identity
.host()
.gunnar_store()
.admit_sso_session(&session.token)
.map_err(|e| Status::unavailable(e.0))?;
answer.session = session.token;
}
self.audit(
METHOD,
&principal_id,
&account,
true,
"sso.login",
&format!(
"protocol={} created={} claimed={}{} second_factor={}",
if flow.saml { "saml" } else { "oidc" },
outcome.created,
outcome.claimed,
if outcome.claimed_by.is_empty() {
String::new()
} else {
format!(" claimed_by={}", outcome.claimed_by)
},
provider.require_passkey
),
);
Ok(Response::new(answer))
}
async fn second_factor(
&self,
request: Request<SecondFactorRequest>,
) -> Result<Response<SecondFactorResponse>, Status> {
const METHOD: &str = "Sso.SecondFactor";
self.relay(&request, METHOD)?;
let request = request.into_inner();
let now = now_ms();
let second = self
.ctx
.sso
.take_second(&hash(request.pending.trim()), now)
.ok_or_else(|| {
Status::permission_denied(nornir_identity_sso::Refusal::Expired.message())
})?;
let refuse = |why: String| {
let why = match nornir_identity_sso::code_of(&why) {
Some(_) => why,
None => nornir_identity_sso::Refusal::SecondFactor.with(&why),
};
self.audit(
METHOD,
&second.principal_id,
&second.account,
false,
"sso.second_factor_refused",
&why,
);
Status::permission_denied(format!("{METHOD} refused: {why}"))
};
let identity = self.ctx.identity.clone().ok_or_else(|| {
Status::unavailable("this server serves no identity core, so it signs nobody in")
})?;
let view = identity
.session(request.session.trim())
.map_err(|refused| {
refuse(format!(
"the passkey's session is no session here ({})",
refused.code
))
})?;
if view.user_id != second.principal_id {
return Err(refuse(
"this passkey is not the signed-in person's".to_owned(),
));
}
identity
.host()
.gunnar_store()
.admit_sso_session(request.session.trim())
.map_err(|e| Status::unavailable(e.0))?;
let principal = self
.ctx
.control
.principal(&second.principal_id)?
.ok_or_else(|| Status::not_found(format!("no principal {}", second.principal_id)))?;
self.audit(
METHOD,
&second.principal_id,
&second.account,
true,
"sso.second_factor",
if second.register {
"registered the first passkey"
} else {
"passkey verified"
},
);
Ok(Response::new(SecondFactorResponse {
principal: Some(principal),
user_verified: view.stepped_up,
}))
}
async fn verify_domain(
&self,
request: Request<VerifyDomainRequest>,
) -> Result<Response<VerifyDomainResponse>, Status> {
const METHOD: &str = "Sso.VerifyDomain";
let (caller, account) = self.admin_stepped_up(&request, |r| r.account.as_str(), METHOD)?;
let raw = request.get_ref().domain.trim().to_owned();
let domain = normalize_domain(&raw)
.ok_or_else(|| Status::invalid_argument(format!("{raw:?} is not an email domain")))?;
let plane = self.plane()?;
let state = Arc::clone(&self.ctx.sso);
let (who, what) = (account.clone(), domain.clone());
let resolver = state.dns_resolver();
let resolver_named = !resolver.trim().is_empty();
let read = match resolver_named {
false => Err(crate::sso::Refusal::DomainResolver.with(NO_RESOLVER)),
true => tokio::task::spawn_blocking(move || {
crate::sso::domains::verify(&state, &who, &what)
})
.await
.map_err(|e| Status::internal(format!("the DNS proof was not read: {e}")))?,
};
let proven = read.and_then(|()| {
plane
.verify_domain(&account, &domain)
.map_err(|e| e.to_string())
});
match proven {
Ok(saved) => {
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
"sso.domain_verified",
&format!("domain={domain}"),
);
Ok(Response::new(VerifyDomainResponse {
view: Some(self.view(saved)),
}))
}
Err(why) => {
self.audit(
METHOD,
&caller.principal_id,
&account,
false,
"sso.domain_refused",
&format!("domain={domain} {why}"),
);
Err(Status::failed_precondition(why))
}
}
}
async fn rebind_users(
&self,
request: Request<RebindUsersRequest>,
) -> Result<Response<RebindUsersResponse>, Status> {
const METHOD: &str = "Sso.RebindUsers";
let (caller, account) = self.admin_stepped_up(&request, |r| r.account.as_str(), METHOD)?;
let request = request.into_inner();
let ids: Vec<String> = request
.principal_ids
.iter()
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty())
.collect();
let rows = self
.plane()?
.rebind_users(&account, &ids, request.external_id.trim())?;
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
"sso.rebind",
&format!(
"employees={} external_id={}",
rows.len(),
!request.external_id.trim().is_empty()
),
);
Ok(Response::new(RebindUsersResponse {
users: rows.into_iter().map(Self::directory_user).collect(),
}))
}
async fn scim(&self, request: Request<ScimRequest>) -> Result<Response<ScimResponse>, Status> {
const METHOD: &str = "Sso.Scim";
self.relay(&request, METHOD)?;
let request = request.into_inner();
let plane = self.plane()?;
let now = now_ms();
let respond = |answer: scim::Answer, ended: Vec<String>| {
let body = if answer.body.is_null() {
Vec::new()
} else {
serde_json::to_vec(&answer.body).unwrap_or_default()
};
Response::new(ScimResponse {
status: u32::from(answer.status),
body,
content_type: scim::CONTENT_TYPE.to_owned(),
location: answer.location,
ended_sessions: ended,
})
};
let bearer = request.bearer.trim();
let token_hash = hash(bearer);
let found = if bearer.is_empty() || bearer.len() > 512 {
None
} else {
plane.scim_account(&token_hash)?
};
let refused = match &found {
None => Some(scim::error_answer(
401,
None,
"the bearer is unknown or revoked",
)),
Some((_, b)) => b.check(now).err(),
};
if let Some(answer) = refused {
let client = request.client.trim();
let detail = answer.body["detail"]
.as_str()
.unwrap_or_default()
.to_owned();
match scim::REFUSALS.refused(client, now) {
scim::Refusal::Audit => self.audit(
METHOD,
"",
found.as_ref().map(|(a, _)| a.as_str()).unwrap_or(""),
false,
"sso.scim_refused",
&format!("{detail} (client {})", scim::bearer::client_key(client)),
),
scim::Refusal::Quiet => {}
scim::Refusal::Throttle => {
return Ok(respond(
scim::error_answer(
429,
None,
"too many refused requests from this client this minute",
),
Vec::new(),
))
}
}
return Ok(respond(answer, Vec::new()));
}
let Some((account, bearer_rules)) = found else {
unreachable!("a missing bearer was refused above");
};
if !self
.ctx
.sso
.allow(&format!("scim:{token_hash}"), SCIM_PER_MINUTE, now)
{
return Ok(respond(
scim::error_answer(429, None, "too many requests this minute"),
Vec::new(),
));
}
let room = self.room(&account)?;
let actor = format!("scim:{account}");
let store = scim::PlaneDirectory {
plane,
account: &account,
room,
actor: &actor,
now_unix_ms: now,
};
let call = scim::Call {
method: request.method.trim(),
path: request.path.trim(),
query: request.query.trim(),
body: &request.body,
base_url: request.base_url.trim(),
read_only: bearer_rules.scope == scim::Scope::Read,
};
let (answer, effect) = scim::handle(&store, &call);
let ended: Vec<String> = effect
.as_ref()
.and_then(scim::Effect::ended_session)
.map(|p| vec![p.to_owned()])
.unwrap_or_default();
if let Some(identity) = &self.ctx.identity {
for principal in &ended {
if let Err(refused) = identity.end_sessions(principal, &format!("scim:{account}")) {
tracing::warn!(target: AUDIT_TARGET, event = "sso.scim_sessions_not_ended", principal = %principal, reason = %refused, "a deactivated user's sessions could not be ended; the directory still refuses them");
}
}
}
if let Some(effect) = effect {
self.audit(
METHOD,
&actor,
&account,
true,
"sso.scim",
&format!("{} {}", effect.what, effect.principal),
);
} else if answer.status >= 400 {
tracing::info!(target: AUDIT_TARGET, event = "sso.scim_answer", account = %account, status = answer.status, method = %call.method, path = %call.path, "a SCIM request was answered with an error");
}
Ok(respond(answer, ended))
}
}
fn import_answer(job: scim::ImportJob, dry_run: bool) -> ImportUsersResponse {
let o = job.outcome;
ImportUsersResponse {
created: o.created,
updated: o.updated,
deactivated: o.deactivated,
reactivated: o.reactivated,
unchanged: o.unchanged,
errors: o.errors,
dry_run,
job_id: job.job_id,
state: job.state.to_owned(),
error: job.error,
done_rows: job.done_rows,
total_rows: job.total_rows,
ended_sessions: o.ended_sessions,
}
}
pub fn with_egress(ctx: &crate::service::ServiceContext, egress: Arc<dyn crate::sso::Egress>) {
ctx.sso.set_egress(egress);
}