Skip to content
Merged
80 changes: 66 additions & 14 deletions src/v1/stage0/src/v1_interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ pub enum InterpError {
DivisionByZero,
Unimplemented { what: String },
EarlyReturn { value: Value },
AuthDeclaredButUnwired { service: String, reason: String },
}

impl fmt::Display for InterpError {
Expand All @@ -600,10 +601,28 @@ impl fmt::Display for InterpError {
InterpError::DivisionByZero => write!(f, "division by zero"),
InterpError::Unimplemented { what } => write!(f, "not yet implemented: {}", what),
InterpError::EarlyReturn { .. } => write!(f, "internal: uncaught early return"),
InterpError::AuthDeclaredButUnwired { service, reason } => write!(
f,
"auth declared but unwired for '{}': {} — refusing to send unauthenticated request",
service, reason
),
}
}
}

/// Three-way auth resolution: splits the conflated `Option<String>` into named states so the
/// dispatch site cannot reach the send path with auth declared but no token (§5 construction).
#[derive(Debug, Clone)]
pub enum AuthResolution {
/// The service declares no auth; unauthenticated send is correct.
NoAuthDeclared,
/// Auth is declared and a non-empty token was resolved; attach the header.
Resolved { header: String, token: String },
/// Auth is declared (svc_auth / svc_auth_input / svc_auth_source present) but no token
/// resolved — the caller must raise a typed error, never send unauthenticated.
DeclaredButUnwired { reason: String },
}

pub type InterpResult<T> = Result<T, InterpError>;

type ServiceOp = (Rc<Node>, Rc<Node>);
Expand Down Expand Up @@ -3849,7 +3868,14 @@ fn dispatch_rest(
None => "GET".to_string(),
};

let (auth_header_name, auth_token) = resolve_auth(service_node, transport, param_env, &si, ctx);
let auth = resolve_auth(service_node, transport, param_env, &si, ctx);
if let AuthResolution::DeclaredButUnwired { ref reason } = auth {
return Err(InterpError::AuthDeclaredButUnwired {
service: find_service_config_string(service_node, "svc_endpoint", &si)
.unwrap_or_else(|| "<unknown>".to_string()),
reason: reason.clone(),
});
}

let reserved_props = [
"base_url",
Expand Down Expand Up @@ -3921,14 +3947,18 @@ fn dispatch_rest(
}
};

if let Some(token) = &auth_token {
if let AuthResolution::Resolved {
ref header,
ref token,
} = auth
{
if !token.is_empty() {
let header_val = if auth_header_name == "Authorization" {
let header_val = if header == "Authorization" {
format!("Bearer {}", token)
} else {
token.clone()
};
request = request.set(&auth_header_name, &header_val);
request = request.set(header, &header_val);
}
}

Expand Down Expand Up @@ -3978,26 +4008,28 @@ fn dispatch_rest(
}
}

fn resolve_auth(
pub fn resolve_auth(
service_node: &Rc<Node>,
_transport: &Rc<Node>,
param_env: &Rc<Env>,
si: &Rc<HashMap<String, Rc<NewlineIndex>>>,
ctx: &InterpContext,
) -> (String, Option<String>) {
) -> AuthResolution {
let mut header_name = "Authorization".to_string();
let mut env_var_name: Option<String> = None;
// `auth_input: <field>` (§3): the token is an operation INPUT the caller supplies,
// not ambient env. Resolve it from the per-call param env. Takes precedence over
// `auth_source` (env var) when both are present.
let mut input_field_name: Option<String> = None;
let mut auth_declared = false;

for prop in service_node.properties.iter() {
let name = field_init_node_name_at(prop.clone(), si.clone());
let val_node = field_init_node_value(prop.clone());

match name.as_str() {
"svc_auth" => {
auth_declared = true;
let scheme = authored_name_at(si.clone(), val_node.clone());
if scheme == "Bearer" {
header_name = "Authorization".to_string();
Expand All @@ -4016,6 +4048,7 @@ fn resolve_auth(
}
}
"svc_auth_input" => {
auth_declared = true;
// `auth_input: access_token` — the value node is the input field name (an identifier).
let field = authored_name_at(si.clone(), val_node.clone());
if !field.is_empty() {
Expand All @@ -4025,6 +4058,7 @@ fn resolve_auth(
}
}
"svc_auth_source" => {
auth_declared = true;
for child in val_node.children.iter() {
let field_name = field_init_node_name_at(child.clone(), si.clone());
if field_name == "name" {
Expand All @@ -4040,19 +4074,37 @@ fn resolve_auth(
}
}

// §3: a caller-supplied input token wins over an ambient env var. Extract the String
// payload explicitly (the token is a Secret = Value::Str) rather than relying on Display —
// a non-string Value must NOT produce a stringified-debug Bearer header; fall through instead.
if let Some(field) = input_field_name {
if let Some(Value::Str(tok)) = param_env.lookup(ctx.sym(&field)) {
if !auth_declared {
return AuthResolution::NoAuthDeclared;
}

// §3: caller-supplied input token wins over ambient env var when non-empty; if the input
// field is absent or empty, fall through to auth_source so dual-declare services
// (auth_input + auth_source) get the env-var fallback. Extract the String payload
// explicitly — a non-Str Value must NOT produce a stringified-debug Bearer header.
if let Some(ref field) = input_field_name {
if let Some(Value::Str(tok)) = param_env.lookup(ctx.sym(field)) {
if !tok.is_empty() {
return (header_name, Some(tok.clone()));
return AuthResolution::Resolved {
header: header_name,
token: tok.clone(),
};
}
}
// input field unresolved or empty — fall through to auth_source attempt below.
}

let token = env_var_name.and_then(|var| resolve_env_var_token(ctx, &var));
(header_name, token)
match env_var_name.and_then(|var| resolve_env_var_token(ctx, &var)) {
Some(tok) if !tok.is_empty() => AuthResolution::Resolved {
header: header_name,
token: tok,
},
_ => AuthResolution::DeclaredButUnwired {
reason: "auth declared but no token resolved (auth_input unresolved/empty, \
auth_source env var absent or empty)"
.to_string(),
},
}
}

fn extract_string_value(node: &Rc<Node>) -> Option<String> {
Expand Down
Loading