use anyhow::Context as _;
use fraiseql_core::security::oidc::MeEndpointConfig;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SecuritySettings {
pub multi_tenant: bool,
pub rls: fraiseql_core::schema::RlsConfig,
pub role_definitions: Vec<crate::config::security::RoleDefinitionConfig>,
pub default_role: Option<String>,
pub rules: Vec<AuthorizationRule>,
pub policies: Vec<AuthorizationPolicy>,
pub field_auth: Vec<FieldAuthRule>,
pub enterprise: EnterpriseSecurityConfig,
pub error_sanitization: Option<ErrorSanitizationTomlConfig>,
pub rate_limiting: Option<RateLimitingSecurityConfig>,
pub state_encryption: Option<StateEncryptionConfig>,
pub pkce: Option<PkceConfig>,
pub api_keys: Option<ApiKeySecurityConfig>,
pub token_revocation: Option<TokenRevocationSecurityConfig>,
pub trusted_documents: Option<TrustedDocumentsConfig>,
pub persisted_queries_only: bool,
pub cost_budget: Option<fraiseql_core::schema::CostBudgetConfig>,
}
pub use fraiseql_core::schema::{
ApiKeySecurityConfig, AuthorizationPolicy, AuthorizationRule, CodeChallengeMethod,
EncryptionAlgorithm, EnterpriseSecurityConfig,
ErrorSanitizationConfig as ErrorSanitizationTomlConfig, FieldAuthRule, KeySource,
PkceSecurityConfig as PkceConfig, RateLimitingSecurityConfig, StateEncryptionConfig,
StaticApiKeyEntry, TokenRevocationSecurityConfig, TrustedDocumentMode, TrustedDocumentsConfig,
};
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct OidcClientConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_uri: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_audiences: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_algorithms: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks_cache_ttl_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clock_skew_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope_claim: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_jti: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub me: Option<MeEndpointConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub additional_issuers: Vec<fraiseql_core::security::oidc::TrustedIssuer>,
#[serde(skip_serializing_if = "Option::is_none")]
pub discovery_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_secret_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_redirect_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub social: Option<fraiseql_core::schema::SocialAuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local: Option<fraiseql_core::schema::LocalAuthConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<fraiseql_core::schema::SessionConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database: Option<fraiseql_core::schema::AuthDatabaseConfig>,
}
impl OidcClientConfig {
pub fn validate(&self) -> anyhow::Result<()> {
let client_fields = [
("discovery_url", self.discovery_url.is_some()),
("client_id", self.client_id.is_some()),
("client_secret_env", self.client_secret_env.is_some()),
("server_redirect_uri", self.server_redirect_uri.is_some()),
];
let client_set = client_fields.iter().filter(|(_, set)| *set).count();
let has_client_group = client_set > 0;
let has_social_group = self.social.is_some();
let has_local_group = self.local.is_some();
let has_jwt_group = self.issuer.is_some() || self.jwks_uri.is_some();
if has_client_group && client_set < client_fields.len() {
let missing: Vec<&str> =
client_fields.iter().filter(|(_, set)| !*set).map(|(name, _)| *name).collect();
anyhow::bail!(
"[auth] PKCE OAuth-client config is incomplete: discovery_url, client_id, \
client_secret_env, and server_redirect_uri must be set together (missing: {}).",
missing.join(", ")
);
}
if self.audience.is_some() && !has_jwt_group {
anyhow::bail!(
"[auth] audience is set but neither issuer nor jwks_uri is configured. JWT \
validation needs an issuer (used for discovery and `iss` validation) or a pinned \
jwks_uri (for IdPs whose tokens omit `iss`, e.g. Hanko) — add one, or remove \
audience."
);
}
if let Some(social) = &self.social {
if social.google.is_none()
&& social.github.is_none()
&& social.apple.is_none()
&& social.discord.is_none()
&& social.facebook.is_none()
{
anyhow::bail!(
"[auth.social] is configured but names no provider. Add an \
[auth.social.{{google,github,apple,discord,facebook}}] block."
);
}
if let Some(apple) = &social.apple {
match (&apple.private_key_env, &apple.private_key_path) {
(Some(_), None) | (None, Some(_)) => {},
(Some(_), Some(_)) => anyhow::bail!(
"[auth.social.apple] names both private_key_env and private_key_path — \
set exactly one."
),
(None, None) => anyhow::bail!(
"[auth.social.apple] needs exactly one of private_key_env / \
private_key_path: the .p8 key is what signs the client-secret assertion."
),
}
}
let mut providers: Vec<(&str, Vec<(&str, &String)>)> = Vec::new();
if let Some(g) = social.google.as_ref() {
providers.push((
"google",
vec![
("client_id", &g.client_id),
("client_secret_env", &g.client_secret_env),
("redirect_uri", &g.redirect_uri),
],
));
}
if let Some(g) = social.github.as_ref() {
providers.push((
"github",
vec![
("client_id", &g.client_id),
("client_secret_env", &g.client_secret_env),
("redirect_uri", &g.redirect_uri),
],
));
}
if let Some(a) = social.apple.as_ref() {
providers.push((
"apple",
vec![
("client_id", &a.client_id),
("team_id", &a.team_id),
("key_id", &a.key_id),
("redirect_uri", &a.redirect_uri),
],
));
}
if let Some(d) = social.discord.as_ref() {
providers.push((
"discord",
vec![
("client_id", &d.client_id),
("client_secret_env", &d.client_secret_env),
("redirect_uri", &d.redirect_uri),
],
));
}
if let Some(f) = social.facebook.as_ref() {
providers.push((
"facebook",
vec![
("client_id", &f.client_id),
("client_secret_env", &f.client_secret_env),
("redirect_uri", &f.redirect_uri),
],
));
}
for (provider, fields) in providers {
for (field, value) in fields {
if value.trim().is_empty() {
anyhow::bail!("[auth.social.{provider}] {field} must not be empty.");
}
}
}
}
if let Some(session) = &self.session {
session.check().map_err(|message| anyhow::anyhow!(message))?;
}
if let Some(database) = &self.database {
database.check().map_err(|message| anyhow::anyhow!(message))?;
}
if let Some(local) = &self.local {
local
.check_session_transport(self.session.as_ref())
.map_err(|message| anyhow::anyhow!(message))?;
if !local.password && !local.otp && !local.mfa && !local.anonymous {
anyhow::bail!(
"[auth.local] is configured but enables no method. Set at least one of \
password / otp / mfa / anonymous, or remove the block."
);
}
if local.email_verification && !local.password {
anyhow::bail!(
"[auth.local] email_verification = true needs password = true: \
verification proves the address a local password identity claims, and \
there is no such identity without it."
);
}
if local.email_verification && local.verification_url_template.is_none() {
anyhow::bail!(
"[auth.local] email_verification = true needs verification_url_template \
— the verification link points at your front end, which FraiseQL cannot \
guess. Example: verification_url_template = \
\"https://app.example.com/verify-email?token={{token}}\""
);
}
if (local.otp || local.password || local.email_verification)
&& local.email_from.is_none()
{
anyhow::bail!(
"[auth.local] enables {} but sets no email_from. These flows deliver mail \
(OTP codes, reset links, verification links); name the [mailbox.<name>] \
account whose SMTP half should send them.",
if local.otp {
"otp"
} else if local.password {
"password"
} else {
"email_verification"
}
);
}
if local.password && local.reset_url_template.is_none() {
anyhow::bail!(
"[auth.local] password = true needs reset_url_template — the reset link \
points at your front end, which FraiseQL cannot guess. Example: \
reset_url_template = \"https://app.example.com/reset?token={{token}}\""
);
}
if let Some(query) = &local.otp_allowed_query {
if !local.otp {
anyhow::bail!(
"[auth.local] otp_allowed_query decides who may sign in by OTP, but otp \
is not enabled. Set otp = true, or remove the key."
);
}
fraiseql_core::schema::otp_allowed_statement(query).map_err(|problem| {
anyhow::anyhow!("[auth.local] otp_allowed_query is refused: {problem}: {query}")
})?;
if local.password || self.social.is_some() {
tracing::warn!(
"[auth.local] otp_allowed_query governs OTP only: password signup and \
social sign-in still create accounts for any address"
);
}
}
if let Some(t) = &local.magic_link_template {
if !t.contains("{token}") && !t.contains("{code}") {
anyhow::bail!(
"[auth.local] magic_link_template must contain the {{token}} \
placeholder, or every link it builds is the same dead link: {t}"
);
}
if !t.contains("{token}") {
tracing::warn!(
"[auth.local] magic_link_template carries only {{code}}: the link \
signs in only where the address is known. Use {{token}}, which \
signs in alone from any device"
);
}
}
local.check_email().map_err(anyhow::Error::msg)?;
for (field, template, placeholder) in [
("reset_url_template", local.reset_url_template.as_ref(), "{token}"),
("verification_url_template", local.verification_url_template.as_ref(), "{token}"),
] {
if let Some(t) = template {
if !t.contains(placeholder) {
anyhow::bail!(
"[auth.local] {field} must contain the {placeholder} placeholder, \
or every link it builds is the same dead link: {t}"
);
}
}
}
}
let has_database = self.database.is_some();
if !has_jwt_group
&& !has_client_group
&& !has_social_group
&& !has_local_group
&& !has_database
{
anyhow::bail!(
"[auth] is empty. Configure JWT validation with issuer (or jwks_uri for IdPs \
whose tokens omit `iss`, e.g. Hanko), a PKCE OAuth-client group \
(discovery_url, client_id, client_secret_env, server_redirect_uri), or \
[auth.social.*] providers. An empty [auth] block does nothing."
);
}
Ok(())
}
}
pub(crate) fn embed_email_templates(
local: &mut fraiseql_core::schema::LocalAuthConfig,
project: &std::path::Path,
) -> anyhow::Result<()> {
let Some(email) = local.email.as_mut() else {
return Ok(());
};
for (kind, template) in [
("otp", email.otp.as_mut()),
("reset", email.reset.as_mut()),
("verification", email.verification.as_mut()),
] {
let Some(template) = template else {
continue;
};
for (key, inline, path, body) in [
("text_template", "text", &mut template.text_template, &mut template.text),
("html_template", "html", &mut template.html_template, &mut template.html),
] {
if body.is_some() {
anyhow::bail!(
"[auth.local.email.{kind}] {inline} is the compiled body. In fraiseql.toml, \
write it in a file and name the file with {key}."
);
}
if let Some(file) = path.take() {
let read = project.join(&file);
let contents = std::fs::read_to_string(&read).with_context(|| {
format!(
"[auth.local.email.{kind}] {key} = {file:?}: cannot read {}",
read.display()
)
})?;
*body = Some(contents);
}
}
}
Ok(())
}