//! Auth-hardening conformance checks.
//!
//! Six pure functions that classify OAuth/OIDC metadata against the
//! six auth-hardening SEPs the 2026-07-28 spec adopts:
//!
//! | SEP | Subject |
//! | --- | --- |
//! | 2468 | RFC 9207 `iss` parameter validation. |
//! | 837  | DCR `application_type` declared. |
//! | 2352 | AS-bound credentials + re-registration on resource migration. |
//! | 2207 | OIDC refresh-token flow. |
//! | 2350 | Scope accumulation on step-up. |
//! | 2351 | `.well-known` URI suffix. |
//!
//! v1 ships the library: each check takes a typed metadata input,
//! returns [`CheckOutcome`] (`Pass` / `Fail` / `NotApplicable`), and
//! stays transport-free so the `mcptest compliance` runner can
//! consume them against a captured server fixture. The live probe
//! integration (when each check fires against a real server) lands
//! in v2 alongside the rest of the conformance pack
//! wiring.

use serde::{Deserialize, Serialize};

/// Outcome of one auth-hardening check.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum CheckOutcome {
    /// The metadata satisfies the rule.
    Pass,
    /// The metadata violates the rule. Carries a short reason the
    /// reporter renders after the check name.
    Fail {
        /// One-line reason rendered next to the check name.
        reason: String,
    },
    /// The metadata does not exercise the rule (a check for OIDC
    /// refresh applied to a plain OAuth flow, say). Carries a short
    /// note so the reporter can surface "skipped because ...".
    NotApplicable {
        /// One-line note rendered next to the check name.
        reason: String,
    },
}

impl CheckOutcome {
    /// True only when the outcome is [`CheckOutcome::Pass`].
    pub fn is_pass(&self) -> bool {
        matches!(self, Self::Pass)
    }

    /// True when the outcome is [`CheckOutcome::Fail`].
    pub fn is_fail(&self) -> bool {
        matches!(self, Self::Fail { .. })
    }
}

/// Input to [`check_iss_present`] (SEP-2468 / RFC 9207).
///
/// The authorization-server response carries an `iss` query parameter
/// identifying the issuer that produced the authorization code. The
/// client must compare it against the configured authorization-server
/// `issuer` URL before accepting the code, otherwise an attacker can
/// route a code from a different AS through the legitimate flow.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssInput<'a> {
    /// Value of the `iss` query parameter the client received from the
    /// authorization endpoint. `None` means the server omitted it.
    pub iss_from_response: Option<&'a str>,
    /// The `issuer` URL the client expected (from its AS metadata).
    pub expected_issuer: &'a str,
}

/// SEP-2468 / RFC 9207: the AS response must carry `iss` and it must
/// match the configured issuer.
pub fn check_iss_present(input: IssInput<'_>) -> CheckOutcome {
    let Some(actual) = input.iss_from_response else {
        return CheckOutcome::Fail {
            reason: "authorization response omitted `iss` (RFC 9207)".into(),
        };
    };
    if actual == input.expected_issuer {
        CheckOutcome::Pass
    } else {
        CheckOutcome::Fail {
            reason: format!(
                "iss `{}` does not match expected issuer `{}`",
                actual, input.expected_issuer
            ),
        }
    }
}

/// Input to [`check_dcr_application_type`] (SEP-837).
///
/// The DCR registration metadata must include an `application_type`
/// field. `native` is required for desktop / CLI clients so the
/// authorization server permits localhost redirect URIs; `web` is the
/// alternative for browser-hosted apps. The historical default was
/// `web`, which silently rejected localhost loopbacks for CLIs and
/// produced cryptic registration failures.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DcrApplicationTypeInput<'a> {
    /// Value the DCR request declared, or `None` when it was omitted.
    pub declared: Option<&'a str>,
    /// Whether the client is a CLI / desktop tool. CLIs MUST declare
    /// `native`; web apps MUST declare `web`.
    pub is_native_client: bool,
}

/// SEP-837: DCR registration must declare an `application_type` and
/// it must match the client's actual deployment shape.
pub fn check_dcr_application_type(input: DcrApplicationTypeInput<'_>) -> CheckOutcome {
    let Some(declared) = input.declared else {
        return CheckOutcome::Fail {
            reason: "DCR registration omitted `application_type` (SEP-837)".into(),
        };
    };
    match (declared, input.is_native_client) {
        ("native", true) | ("web", false) => CheckOutcome::Pass,
        ("native", false) => CheckOutcome::Fail {
            reason: "web client declared `application_type: native`".into(),
        },
        ("web", true) => CheckOutcome::Fail {
            reason: "native / CLI client declared `application_type: web` (will reject localhost)"
                .into(),
        },
        (other, _) => CheckOutcome::Fail {
            reason: format!(
                "unknown `application_type` value `{other}` (must be `native` or `web`)"
            ),
        },
    }
}

/// Input to [`check_as_bound_credentials`] (SEP-2352).
///
/// When the resource a client targets migrates between authorization
/// servers (a tenant-owned AS replaces a public one, say), the client's
/// existing credentials are bound to the previous AS and must be
/// re-registered against the new one. A client that simply retries with
/// the old credentials authenticates to the wrong issuer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AsBoundCredentialsInput<'a> {
    /// AS issuer the credentials were originally issued for.
    pub credentials_bound_to: &'a str,
    /// AS issuer the resource currently advertises.
    pub resource_current_as: &'a str,
    /// Whether the client re-registered against the current AS after
    /// the resource migrated.
    pub reregistered_against_current_as: bool,
}

/// SEP-2352: credentials are bound to a specific AS; a resource
/// migration requires re-registration before reuse.
pub fn check_as_bound_credentials(input: AsBoundCredentialsInput<'_>) -> CheckOutcome {
    if input.credentials_bound_to == input.resource_current_as {
        return CheckOutcome::Pass;
    }
    if input.reregistered_against_current_as {
        CheckOutcome::Pass
    } else {
        CheckOutcome::Fail {
            reason: format!(
                "credentials bound to `{}` but resource now uses `{}`; re-register before reuse",
                input.credentials_bound_to, input.resource_current_as
            ),
        }
    }
}

/// Input to [`check_oidc_refresh`] (SEP-2207).
///
/// An OIDC client that received `offline_access` plus a refresh token
/// must rotate the refresh token on every use and surface a clear error
/// when the AS rejects the rotation (revoked grant, scope downgrade,
/// etc.). A client that silently retries with the prior refresh token
/// after a rotation reuses a credential the AS has already invalidated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OidcRefreshInput<'a> {
    /// Did the original grant include the `offline_access` scope?
    pub granted_offline_access: bool,
    /// Did the AS return a refresh token alongside the access token?
    pub refresh_token_present: bool,
    /// The new refresh token after a rotation, if any. `Some` means the
    /// client rotated; `None` means the client did not rotate.
    pub rotated_refresh_token: Option<&'a str>,
}

/// SEP-2207: OIDC clients must rotate the refresh token on every use
/// when `offline_access` was granted.
pub fn check_oidc_refresh(input: OidcRefreshInput<'_>) -> CheckOutcome {
    if !input.granted_offline_access {
        return CheckOutcome::NotApplicable {
            reason: "grant did not include `offline_access`; OIDC refresh rule does not apply"
                .into(),
        };
    }
    if !input.refresh_token_present {
        return CheckOutcome::Fail {
            reason: "`offline_access` granted but AS did not return a refresh token".into(),
        };
    }
    match input.rotated_refresh_token {
        Some(token) if !token.is_empty() => CheckOutcome::Pass,
        _ => CheckOutcome::Fail {
            reason: "refresh token was not rotated on use (SEP-2207)".into(),
        },
    }
}

/// Input to [`check_scope_step_up_accumulation`] (SEP-2350).
///
/// A step-up request asks the AS for additional scopes on top of the
/// existing ones. The new grant must include both the previously held
/// scopes and the newly requested ones; an AS that returns only the new
/// scopes silently downgrades the client and breaks every prior call.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopeStepUpInput<'a> {
    /// Scopes the client held before the step-up.
    pub previous_scopes: &'a [&'a str],
    /// Scopes the step-up request asked for.
    pub requested_step_up: &'a [&'a str],
    /// Scopes the AS returned in the new grant.
    pub granted: &'a [&'a str],
}

/// SEP-2350: a step-up grant accumulates the previously held scopes
/// with the newly requested ones.
pub fn check_scope_step_up_accumulation(input: ScopeStepUpInput<'_>) -> CheckOutcome {
    let granted = input.granted;
    let missing: Vec<&str> = input
        .previous_scopes
        .iter()
        .chain(input.requested_step_up.iter())
        .filter(|scope| !granted.iter().any(|g| g == *scope))
        .copied()
        .collect();
    if missing.is_empty() {
        CheckOutcome::Pass
    } else {
        CheckOutcome::Fail {
            reason: format!(
                "step-up grant missing required scopes: {}",
                missing.join(", ")
            ),
        }
    }
}

/// Input to [`check_well_known_suffix`] (SEP-2351).
///
/// RFC 8414 puts the AS metadata document at
/// `<issuer>/.well-known/oauth-authorization-server`. The 2026-07-28
/// spec tightens the suffix rule: every `.well-known` URI the client
/// resolves must end with `/.well-known/<id>` exactly, not embed
/// `.well-known` in a path segment further left. Implementations that
/// accept a non-suffix form open up a server-side request forgery
/// vector when a downstream URL builder concatenates without a slash.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WellKnownSuffixInput<'a> {
    /// Full URL the client resolved.
    pub url: &'a str,
}

/// SEP-2351: a `.well-known` URI must have `/.well-known/<id>` as a
/// terminal suffix (no path component after the id).
pub fn check_well_known_suffix(input: WellKnownSuffixInput<'_>) -> CheckOutcome {
    let url = input.url;
    let Some(pos) = url.find("/.well-known/") else {
        return CheckOutcome::NotApplicable {
            reason: "URL does not reference a `.well-known` document".into(),
        };
    };
    // Everything after `/.well-known/` is the identifier; it must be
    // non-empty and contain no further `/` so the suffix is terminal.
    let after = &url[pos + "/.well-known/".len()..];
    if after.is_empty() {
        return CheckOutcome::Fail {
            reason: "`.well-known` URI missing identifier suffix".into(),
        };
    }
    // Strip trailing query/fragment before checking for embedded slashes
    // so `/.well-known/openid-configuration?foo=bar` reads as terminal.
    let after_path = after
        .split_once(['?', '#'])
        .map(|(p, _)| p)
        .unwrap_or(after);
    if after_path.contains('/') {
        return CheckOutcome::Fail {
            reason: format!(
                "`.well-known` identifier `{after_path}` is not a terminal suffix (contains `/`)"
            ),
        };
    }
    CheckOutcome::Pass
}

#[cfg(test)]
mod tests {
    use super::*;

    // === SEP-2468: iss ===

    #[test]
    fn iss_present_and_matching_passes() {
        assert!(check_iss_present(IssInput {
            iss_from_response: Some("https://as.example.com"),
            expected_issuer: "https://as.example.com",
        })
        .is_pass());
    }

    #[test]
    fn iss_missing_fails_with_rfc9207_note() {
        let outcome = check_iss_present(IssInput {
            iss_from_response: None,
            expected_issuer: "https://as.example.com",
        });
        match outcome {
            CheckOutcome::Fail { reason } => assert!(reason.contains("RFC 9207")),
            other => panic!("expected Fail, got {other:?}"),
        }
    }

    #[test]
    fn iss_mismatch_fails_with_both_values_in_reason() {
        let outcome = check_iss_present(IssInput {
            iss_from_response: Some("https://attacker.example"),
            expected_issuer: "https://as.example.com",
        });
        match outcome {
            CheckOutcome::Fail { reason } => {
                assert!(reason.contains("attacker.example"));
                assert!(reason.contains("as.example.com"));
            }
            other => panic!("expected Fail, got {other:?}"),
        }
    }

    // === SEP-837: DCR application_type ===

    #[test]
    fn native_client_with_native_declaration_passes() {
        assert!(check_dcr_application_type(DcrApplicationTypeInput {
            declared: Some("native"),
            is_native_client: true,
        })
        .is_pass());
    }

    #[test]
    fn web_client_with_web_declaration_passes() {
        assert!(check_dcr_application_type(DcrApplicationTypeInput {
            declared: Some("web"),
            is_native_client: false,
        })
        .is_pass());
    }

    #[test]
    fn cli_client_declaring_web_fails_with_localhost_warning() {
        let outcome = check_dcr_application_type(DcrApplicationTypeInput {
            declared: Some("web"),
            is_native_client: true,
        });
        match outcome {
            CheckOutcome::Fail { reason } => assert!(reason.contains("localhost")),
            other => panic!("expected Fail, got {other:?}"),
        }
    }

    #[test]
    fn missing_application_type_fails_with_sep_note() {
        let outcome = check_dcr_application_type(DcrApplicationTypeInput {
            declared: None,
            is_native_client: true,
        });
        match outcome {
            CheckOutcome::Fail { reason } => assert!(reason.contains("SEP-837")),
            other => panic!("expected Fail, got {other:?}"),
        }
    }

    #[test]
    fn unknown_application_type_value_fails() {
        let outcome = check_dcr_application_type(DcrApplicationTypeInput {
            declared: Some("desktop"),
            is_native_client: true,
        });
        assert!(outcome.is_fail());
    }

    // === SEP-2352: AS-bound credentials ===

    #[test]
    fn credentials_against_unchanged_as_pass_without_reregistration() {
        assert!(check_as_bound_credentials(AsBoundCredentialsInput {
            credentials_bound_to: "https://as.example.com",
            resource_current_as: "https://as.example.com",
            reregistered_against_current_as: false,
        })
        .is_pass());
    }

    #[test]
    fn migrated_as_without_reregistration_fails() {
        let outcome = check_as_bound_credentials(AsBoundCredentialsInput {
            credentials_bound_to: "https://old.example.com",
            resource_current_as: "https://new.example.com",
            reregistered_against_current_as: false,
        });
        assert!(outcome.is_fail());
    }

    #[test]
    fn migrated_as_with_reregistration_passes() {
        assert!(check_as_bound_credentials(AsBoundCredentialsInput {
            credentials_bound_to: "https://old.example.com",
            resource_current_as: "https://new.example.com",
            reregistered_against_current_as: true,
        })
        .is_pass());
    }

    // === SEP-2207: OIDC refresh ===

    #[test]
    fn grant_without_offline_access_is_not_applicable() {
        let outcome = check_oidc_refresh(OidcRefreshInput {
            granted_offline_access: false,
            refresh_token_present: false,
            rotated_refresh_token: None,
        });
        assert!(matches!(outcome, CheckOutcome::NotApplicable { .. }));
    }

    #[test]
    fn offline_access_without_refresh_token_fails() {
        let outcome = check_oidc_refresh(OidcRefreshInput {
            granted_offline_access: true,
            refresh_token_present: false,
            rotated_refresh_token: None,
        });
        assert!(outcome.is_fail());
    }

    #[test]
    fn refresh_token_not_rotated_fails_with_sep_note() {
        let outcome = check_oidc_refresh(OidcRefreshInput {
            granted_offline_access: true,
            refresh_token_present: true,
            rotated_refresh_token: None,
        });
        match outcome {
            CheckOutcome::Fail { reason } => assert!(reason.contains("SEP-2207")),
            other => panic!("expected Fail, got {other:?}"),
        }
    }

    #[test]
    fn refresh_token_rotated_passes() {
        assert!(check_oidc_refresh(OidcRefreshInput {
            granted_offline_access: true,
            refresh_token_present: true,
            rotated_refresh_token: Some("new-rt-12345"),
        })
        .is_pass());
    }

    // === SEP-2350: step-up scope accumulation ===

    #[test]
    fn step_up_grant_accumulating_both_scope_sets_passes() {
        assert!(check_scope_step_up_accumulation(ScopeStepUpInput {
            previous_scopes: &["read"],
            requested_step_up: &["write"],
            granted: &["read", "write"],
        })
        .is_pass());
    }

    #[test]
    fn step_up_grant_that_drops_a_previous_scope_fails() {
        let outcome = check_scope_step_up_accumulation(ScopeStepUpInput {
            previous_scopes: &["read", "list"],
            requested_step_up: &["write"],
            granted: &["read", "write"],
        });
        match outcome {
            CheckOutcome::Fail { reason } => assert!(reason.contains("list")),
            other => panic!("expected Fail, got {other:?}"),
        }
    }

    #[test]
    fn step_up_grant_missing_requested_scope_fails() {
        let outcome = check_scope_step_up_accumulation(ScopeStepUpInput {
            previous_scopes: &["read"],
            requested_step_up: &["write"],
            granted: &["read"],
        });
        match outcome {
            CheckOutcome::Fail { reason } => assert!(reason.contains("write")),
            other => panic!("expected Fail, got {other:?}"),
        }
    }

    // === SEP-2351: .well-known suffix ===

    #[test]
    fn well_known_with_terminal_identifier_passes() {
        assert!(check_well_known_suffix(WellKnownSuffixInput {
            url: "https://as.example.com/.well-known/oauth-authorization-server",
        })
        .is_pass());
    }

    #[test]
    fn well_known_with_trailing_query_is_still_terminal() {
        assert!(check_well_known_suffix(WellKnownSuffixInput {
            url: "https://as.example.com/.well-known/openid-configuration?cache=0",
        })
        .is_pass());
    }

    #[test]
    fn non_well_known_url_is_not_applicable() {
        let outcome = check_well_known_suffix(WellKnownSuffixInput {
            url: "https://as.example.com/oauth/token",
        });
        assert!(matches!(outcome, CheckOutcome::NotApplicable { .. }));
    }

    #[test]
    fn well_known_with_extra_path_component_fails() {
        let outcome = check_well_known_suffix(WellKnownSuffixInput {
            url: "https://as.example.com/.well-known/oauth-authorization-server/foo",
        });
        assert!(outcome.is_fail());
    }

    #[test]
    fn well_known_with_empty_identifier_fails() {
        let outcome = check_well_known_suffix(WellKnownSuffixInput {
            url: "https://as.example.com/.well-known/",
        });
        assert!(outcome.is_fail());
    }
}
