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, ImportUsersRequest, ImportUsersResponse, ListDirectoryRequest,
ListDirectoryResponse, ListScimTokensRequest, ListScimTokensResponse, ProviderView,
RemoveProviderRequest, RemoveProviderResponse, RevokeScimTokenRequest, RevokeScimTokenResponse,
ScimRequest, ScimResponse, SecondFactorNeed, SecondFactorRequest, SecondFactorResponse,
SetProviderRequest, SetProviderResponse, SetUserActiveRequest, SetUserActiveResponse,
SsoProtocol, SsoProvider,
};
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, BEGINS_PER_MINUTE, FAILURES_PER_MINUTE, SCIM_PER_MINUTE,
SECOND_TTL_MS,
};
const SESSION_MAX_MS: i64 = crate::service::access::WEB_SESSION_MAX_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 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<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()
},
provider: Some(shown),
}
}
fn directory_user(user: crate::sso::plane::DirUser) -> DirectoryUser {
user.user
}
}
#[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(
&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"))
})?;
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(),
};
let secret;
let mut ca_said = String::new();
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(
&nornir_auth_oidc::rp::discovery_url(&issuer),
loopback,
&self.ctx.sso.private_nets(),
)
.map_err(|why| Status::invalid_argument(format!("the issuer: {why}")))?;
let stored_ca = stored
.as_ref()
.and_then(|p| p.oidc.as_ref())
.map(oidc::ca_of)
.unwrap_or_default();
let (ca, ca_change) = match (request.clear_idp_ca, o.idp_ca_pem.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",
),
};
ca_said = format!(
" idp_ca={} ({ca_change})",
if ca.is_empty() {
"none".to_owned()
} else {
ca.iter().map(|d| saml::fingerprint(d)).collect::<Vec<_>>().join(",")
}
);
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(
&o.display_name_claim,
"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 Some(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 keep_certs = s.idp_certificates.iter().all(|c| c.starts_with("SHA256:"))
&& s.idp_metadata_xml.trim().is_empty()
&& s.idp_metadata_url.trim().is_empty();
if keep_certs {
s.idp_certificates = stored.as_ref().and_then(|p| p.saml.as_ref()).map(|p| {
p.idp_certificates.iter().map(|b64| format!("-----BEGIN CERTIFICATE-----\n{b64}\n-----END CERTIFICATE-----")).collect()
}).unwrap_or_default();
}
let resolved = saml::resolve_input(&self.ctx.sso, self.ctx.sso.egress(), &s)
.await
.map_err(Status::invalid_argument)?;
provider.saml = Some(resolved);
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));
Ok(Response::new(GetProviderResponse { view }))
}
async fn remove_provider(
&self,
request: Request<RemoveProviderRequest>,
) -> Result<Response<RemoveProviderResponse>, Status> {
const METHOD: &str = "Sso.RemoveProvider";
let (caller, account) = self.admin(&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(&request, |r| r.account.as_str(), METHOD)?;
let label = request
.get_ref()
.label
.trim()
.chars()
.take(128)
.collect::<String>();
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,
)?;
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
"sso.scim_token_created",
&format!("token={id}"),
);
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")?;
Ok(Response::new(ListScimTokensResponse {
tokens: self.plane()?.scim_tokens(&account)?,
}))
}
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(&request, |r| r.account.as_str(), METHOD)?;
let request = request.into_inner();
let room = self.room(&account)?;
let plane = self.plane()?;
let done = scim::import_csv(
plane,
&account,
&request.csv,
request.dry_run,
&room,
&format!("csv:{}", caller.principal_id),
now_ms(),
)
.map_err(Status::invalid_argument)?;
if !request.dry_run {
self.audit(
METHOD,
&caller.principal_id,
&account,
true,
"sso.csv_import",
&format!(
"created={} updated={} deactivated={} reactivated={} errors={}",
done.created,
done.updated,
done.deactivated,
done.reactivated,
done.errors.len()
),
);
}
Ok(Response::new(ImportUsersResponse {
created: done.created,
updated: done.updated,
deactivated: done.deactivated,
reactivated: done.reactivated,
unchanged: done.unchanged,
errors: done.errors,
dry_run: request.dry_run,
}))
}
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 account = match hint.rsplit_once('@') {
Some((_, domain)) => match normalize_domain(domain) {
Some(d) => plane.account_for_domain(&d)?,
None => None,
},
None if hint.contains('.') => match normalize_domain(&hint) {
Some(d) => match plane.account_for_domain(&d)? {
Some(a) => Some(a),
None => Some(hint.clone()),
},
None => None,
},
None => Some(hint.clone()),
};
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();
if !self
.ctx
.sso
.allow(&format!("begin:{account}"), BEGINS_PER_MINUTE, now)
{
self.audit(
METHOD,
"",
&account,
false,
"sso.rate_limited",
"too many sign-ins begun this minute",
);
return Err(Status::resource_exhausted(format!(
"too many sign-ins to {account} 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().ok_or_else(|| {
Status::unavailable("this server reaches no identity provider")
})?;
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;
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("this sign-in expired or was already used; start again")
})?;
let account = flow.account.clone();
let refuse = |why: String| {
self.ctx
.sso
.allow(&format!("fail:{account}"), u32::MAX, now);
self.audit(METHOD, "", &account, false, "sso.login_refused", &why);
Status::permission_denied(format!("single sign-on refused: {why}"))
};
if self.ctx.sso.counted(&format!("fail:{account}"), now) >= FAILURES_PER_MINUTE {
self.audit(
METHOD,
"",
&account,
false,
"sso.rate_limited",
"too many failed sign-ins this minute",
);
return Err(Status::resource_exhausted(
"too many failed sign-ins to this account this minute; try again shortly",
));
}
if !same_origin(&request.console_origin, &flow.console_origin) {
return Err(refuse(
"the sign-in came back to another console".to_owned(),
));
}
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 session_key = request.session_public_key.trim().to_owned();
ssh_key::PublicKey::from_openssh(&session_key).map_err(|e| {
Status::invalid_argument(format!(
"`session_public_key` is not an OpenSSH public key: {e}"
))
})?;
if request.session_expires_at_unix_ms <= now {
return Err(Status::invalid_argument(
"`session_expires_at_unix_ms` must be in the future",
));
}
let expires = request.session_expires_at_unix_ms.min(now + SESSION_MAX_MS);
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(
"the account's identity provider changed while the sign-in was out".to_owned(),
))
}
};
let protocol = protocol_of(&provider);
let (issuer, subject, user_name, display_name, email) = match (protocol, flow.saml) {
(SsoProtocol::Oidc, false) => {
let egress = self.ctx.sso.egress().ok_or_else(|| {
Status::unavailable("this server reaches no identity provider")
})?;
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)
}
(SsoProtocol::Saml, true) => {
let v = saml::finish(
&self.ctx.sso,
&provider,
&flow,
request.saml_response.trim(),
now,
)
.map_err(refuse)?;
(v.issuer, v.subject, v.user_name, v.display_name, v.email)
}
_ => {
return Err(refuse(
"the provider's protocol changed while the sign-in was out".to_owned(),
))
}
};
let user_name = normalize_user_name(&user_name)
.ok_or_else(|| refuse("the identity provider named nobody".to_owned()))?;
let room = self.room(&account)?;
let outcome = plane
.login(SsoLoginAsk {
account: account.clone(),
issuer,
subject,
user_name: user_name.clone(),
display_name,
email,
room,
session: (!provider.require_passkey).then(|| (session_key.clone(), expires)),
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: session_key,
session_expires_ms: expires,
expires_ms: now + SECOND_TTL_MS,
},
now,
)
.map_err(Status::resource_exhausted)?;
answer.second_factor = if register {
SecondFactorNeed::PasskeyRegistration
} else {
SecondFactorNeed::Passkey
} as i32;
answer.pending = ticket;
answer.credential_ids = passkeys;
} else {
answer.fingerprint = outcome.fingerprint.clone().unwrap_or_default();
answer.session_expires_at_unix_ms = expires;
}
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,
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("this sign-in expired or was already used; start again")
})?;
let refuse = |why: String| {
self.audit(
METHOD,
&second.principal_id,
&second.account,
false,
"sso.second_factor_refused",
&why,
);
Status::permission_denied(format!("{METHOD} refused: {why}"))
};
if request.rp_id.trim().is_empty() || request.origin.trim().is_empty() {
return Err(Status::invalid_argument(
"`rp_id` and `origin` are both required",
));
}
crate::webauthn::check_credential_id(&request.credential_id)
.map_err(Status::invalid_argument)?;
let data = crate::webauthn::client_data(&request.client_data_json).map_err(refuse)?;
let fresh = self.ctx.web_challenges.take(&data.challenge, now);
let kind = if second.register {
"webauthn.create"
} else {
"webauthn.get"
};
crate::webauthn::check_ceremony(&data, kind, &request.origin, &request.rp_id)
.and_then(|()| crate::webauthn::check_relying_party(&self.ctx.config.web_relying_parties, &request.rp_id, &request.origin))
.map_err(refuse)?;
if !fresh {
return Err(refuse(
"the challenge was not issued here, was already used, or expired".to_owned(),
));
}
let comment = format!("web session (sso {} + passkey)", second.account);
let (credential_id, sign_count, user_verified) = if second.register {
crate::webauthn::check_public_key(&request.public_key)
.map_err(Status::invalid_argument)?;
let attested = crate::webauthn::verify_attestation(
&request.attestation_object,
&request.client_data_json,
request.rp_id.trim(),
&request.credential_id,
&request.public_key,
)
.map_err(refuse)?;
let label = match request.label.trim() {
"" => "passkey".to_owned(),
l => l.chars().take(128).collect(),
};
self.ctx
.control
.web_register(crate::seam::WebRegistration {
invite_secret_hash: None,
principal_id: Some(second.principal_id.clone()),
credential_id: request.credential_id.clone(),
public_key: request.public_key.clone(),
label,
sign_count: attested.sign_count,
now_unix_ms: now,
})
.map_err(|e| refuse(e.to_string()))?;
(request.credential_id.clone(), 0u32, false)
} else {
let stored = self
.ctx
.control
.credential(&request.credential_id)?
.filter(|c| c.credential.principal_id == second.principal_id)
.ok_or_else(|| refuse("this passkey is not the signed-in person's".to_owned()))?;
let verified = crate::webauthn::verify_assertion(
&stored.public_key,
stored.credential.sign_count,
&request.client_data_json,
&request.authenticator_data,
&request.signature,
request.rp_id.trim(),
false,
)
.map_err(refuse)?;
(
request.credential_id.clone(),
verified.sign_count,
verified.user_verified,
)
};
let outcome = self
.ctx
.control
.web_login(crate::seam::WebLoginCommit {
credential_id,
sign_count,
now_unix_ms: now,
session_openssh: second.session_openssh.clone(),
comment,
expires_at_unix_ms: second.session_expires_ms,
})
.map_err(|e| refuse(e.to_string()))?;
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(outcome.principal),
fingerprint: outcome.fingerprint,
session_expires_at_unix_ms: second.session_expires_ms,
user_verified,
credential_label: outcome.credential.label,
}))
}
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 account = if bearer.is_empty() || bearer.len() > 512 {
None
} else {
plane.scim_account(&token_hash)?
};
let Some(account) = account else {
self.audit(
METHOD,
"",
"",
false,
"sso.scim_refused",
"an unknown or revoked bearer",
);
return Ok(respond(scim::Answer {
status: 401,
body: serde_json::json!({ "schemas": [scim::ERROR_SCHEMA], "status": "401", "detail": "the bearer is unknown or revoked" }),
location: String::new(),
}, Vec::new()));
};
if !self
.ctx
.sso
.allow(&format!("scim:{token_hash}"), SCIM_PER_MINUTE, now)
{
return Ok(respond(scim::Answer {
status: 429,
body: serde_json::json!({ "schemas": [scim::ERROR_SCHEMA], "status": "429", "detail": "too many requests this minute" }),
location: String::new(),
}, Vec::new()));
}
let room = self.room(&account)?;
let actor = format!("scim:{account}");
let call = scim::Call {
account: &account,
method: request.method.trim(),
path: request.path.trim(),
query: request.query.trim(),
body: &request.body,
base_url: request.base_url.trim(),
room,
actor: &actor,
now_unix_ms: now,
};
let (answer, effect) = scim::handle(plane, &call);
let ended: Vec<String> = effect
.as_ref()
.filter(|e| matches!(e.what, "deactivated" | "deleted"))
.map(|e| vec![e.principal.clone()])
.unwrap_or_default();
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))
}
}
pub fn with_egress(ctx: &crate::service::ServiceContext, egress: Arc<dyn crate::sso::Egress>) {
ctx.sso.set_egress(egress);
}