use apiplant_auth::Authenticator;
use apiplant_core::AuthEvent;
use apiplant_oauth::{Profile, Provider};
use chrono::{Duration, Utc};
use ntex::web::types::{Json, Path, State};
use ntex::web::{HttpRequest, HttpResponse};
use serde_json::{json, Map, Value};
use uuid::Uuid;
use crate::auth_routes::{auth_spec, table, VERIFIED_AT_FIELD};
use crate::crud::parse_query;
use crate::hooks::{self, HookRequest};
use crate::response::{db_error, error};
use crate::state::AppState;
const OPTIONAL_CONNECTION_FIELDS: &[&str] = &[
"email",
"email_verified",
"display_name",
"avatar_url",
"last_login_at",
];
const REQUIRED_CONNECTION_FIELDS: &[&str] =
&["provider", "provider_user_id", "provider_key", "owner_id"];
const PLACEHOLDER_DOMAIN: &str = "oauth.invalid";
pub fn check_resources(app: &apiplant_core::App) -> Result<(), String> {
if !app.config.oauth.enabled() {
return Ok(());
}
for (name, required) in [
("oauth_connection", REQUIRED_CONNECTION_FIELDS),
(
"oauth_state",
&["provider", "state_hash", "expires_at", "used_at"][..],
),
] {
let Some(resource) = app.resources.get(name) else {
return Err(format!(
"[oauth] is configured but the `{name}` resource is missing"
));
};
for field in required {
if !resource.fields.contains_key(*field) {
return Err(format!(
"[oauth] is configured, so `{name}` needs a `{field}` field — \
resources/{name}.toml replaces the built-in, which has one"
));
}
}
}
Ok(())
}
pub async fn providers(state: State<AppState>) -> HttpResponse {
let Some(oauth) = &state.oauth else {
return error(404, "this app has no OAuth providers configured");
};
HttpResponse::Ok().json(&json!({ "providers": oauth.describe(&callback_base(&state)) }))
}
pub async fn start_redirect(
req: HttpRequest,
state: State<AppState>,
path: Path<String>,
) -> HttpResponse {
match begin(&req, &state, &path.into_inner()).await {
Ok(flow) => HttpResponse::Found()
.header("location", flow.authorize_url)
.header("cache-control", "no-store")
.finish(),
Err(response) => response,
}
}
pub async fn start_json(
req: HttpRequest,
state: State<AppState>,
path: Path<String>,
) -> HttpResponse {
match begin(&req, &state, &path.into_inner()).await {
Ok(flow) => HttpResponse::Ok().json(&json!({
"provider": flow.provider,
"authorize_url": flow.authorize_url,
"state": flow.state,
"expires_in": state.app.config.oauth.state_ttl(),
"linking": flow.linking,
})),
Err(response) => response,
}
}
struct Started {
provider: String,
authorize_url: String,
state: String,
linking: bool,
}
async fn begin(
req: &HttpRequest,
state: &State<AppState>,
provider_key: &str,
) -> Result<Started, HttpResponse> {
let provider = resolve(state, provider_key)?;
if state.requires_email_verification() && !provider.provides_email {
return Err(error(
400,
format!(
"{} cannot be used here: it releases no email address, and this app \
requires a confirmed one",
provider.label
),
));
}
let flow = provider.start().map_err(oauth_error)?;
let link_user_id = state.resolve_principal(req).await.map(|p| p.user_id);
let Some(state_r) = state.app.resources.get("oauth_state") else {
return Err(error(500, "no oauth_state resource"));
};
let mut row = Map::new();
row.insert("provider".into(), json!(provider.key));
row.insert("state_hash".into(), json!(flow.state_hash));
if let Some(verifier) = &flow.verifier {
row.insert("verifier".into(), json!(verifier));
}
row.insert("redirect_uri".into(), json!(flow.redirect_uri));
if let Some(user_id) = link_user_id {
row.insert("link_user_id".into(), json!(user_id.to_string()));
}
let params = parse_query(req.query_string());
row.insert("return_to".into(), json!(return_to(state, ¶ms)));
if let Some(delivery) = requested_delivery(¶ms) {
row.insert("token_delivery".into(), json!(delivery));
}
row.insert(
"expires_at".into(),
json!(
(Utc::now() + Duration::seconds(state.app.config.oauth.state_ttl() as i64))
.to_rfc3339()
),
);
state
.db
.create(state_r, &keep_declared(state_r, row))
.await
.map_err(db_error)?;
sweep_expired(state).await;
Ok(Started {
provider: provider.key.clone(),
authorize_url: flow.authorize_url,
state: flow.state,
linking: link_user_id.is_some(),
})
}
#[derive(Debug, Default, serde::Deserialize)]
pub struct Callback {
#[serde(default)]
code: String,
#[serde(default)]
state: String,
#[serde(default)]
error: String,
#[serde(default)]
error_description: String,
}
pub async fn callback_redirect(
req: HttpRequest,
state: State<AppState>,
path: Path<String>,
) -> HttpResponse {
let params = parse_query(req.query_string());
let incoming = Callback {
code: params.get("code").cloned().unwrap_or_default(),
state: params.get("state").cloned().unwrap_or_default(),
error: params.get("error").cloned().unwrap_or_default(),
error_description: params.get("error_description").cloned().unwrap_or_default(),
};
let provider = path.into_inner();
match complete(&req, &state, &provider, incoming).await {
Ok(success) => deliver(success),
Err(response) => failure(&state, response),
}
}
pub async fn callback_json(
req: HttpRequest,
state: State<AppState>,
path: Path<String>,
body: Json<Callback>,
) -> HttpResponse {
match complete(&req, &state, &path.into_inner(), body.into_inner()).await {
Ok(success) => HttpResponse::Ok().json(&success.body),
Err(response) => response,
}
}
struct Success {
body: Value,
token: String,
return_to: String,
delivery: String,
}
async fn complete(
req: &HttpRequest,
state: &State<AppState>,
provider_key: &str,
incoming: Callback,
) -> Result<Success, HttpResponse> {
let provider = resolve(state, provider_key)?;
if !incoming.error.is_empty() {
let detail = match incoming.error_description.trim() {
"" => incoming.error.as_str(),
described => described,
};
return Err(error(
400,
format!("{} did not sign you in: {detail}", provider.label),
));
}
if incoming.code.trim().is_empty() || incoming.state.trim().is_empty() {
return Err(error(400, "that redirect carried no code and state"));
}
let flow = claim_state(state, &provider.key, incoming.state.trim()).await?;
let redirect_uri = flow
.get("redirect_uri")
.and_then(|v| v.as_str())
.unwrap_or(&provider.redirect_uri)
.to_string();
let verifier = flow.get("verifier").and_then(|v| v.as_str());
let link_user_id = flow
.get("link_user_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok());
let delivery = flow
.get("token_delivery")
.and_then(|v| v.as_str())
.filter(|value| !value.is_empty())
.unwrap_or(&state.app.config.oauth.token_delivery)
.trim()
.to_lowercase();
let return_to = flow
.get("return_to")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(&state.app.config.oauth.success_redirect)
.to_string();
let Some(oauth) = &state.oauth else {
return Err(error(404, "this app has no OAuth providers configured"));
};
let access_token = oauth
.exchange_code(provider, incoming.code.trim(), verifier, &redirect_uri)
.await
.map_err(oauth_error)?;
let profile = oauth
.fetch_profile(provider, &access_token)
.await
.map_err(oauth_error)?;
let account = resolve_account(req, state, provider, &profile, link_user_id).await?;
let owner_id = link_connection(state, provider, &profile, &account).await?;
let user = read_user(state, owner_id).await?;
if state.requires_email_verification() && !verified_in(state, &user) {
return Err(HttpResponse::Ok().json(&json!({
"user": user,
"verification_required": true,
"message": "Check your email to confirm your address, then sign in.",
})));
}
let token = state
.auth
.issue_token(owner_id)
.map_err(|_| error(500, "failed to issue token"))?;
let mut body = json!({
"token": token,
"user": user,
"provider": provider.key,
"created": account.created,
"linked": account.linking,
});
if let Some(user_r) = state.app.resources.get("user") {
let hook_req = HookRequest::new(req, &parse_query(req.query_string()), None, None)
.with_record(owner_id);
let outcome = json!({
"success": true,
"method": "oauth",
"provider": provider.key,
"user_id": owner_id.to_string(),
"identity": user.get(&auth_spec(state).identity_field).cloned().unwrap_or(Value::Null),
"created": account.created,
"reason": Value::Null,
});
match hooks::run_auth(state, user_r, AuthEvent::AfterLogin, &hook_req, outcome).await {
Ok(Some(replacement)) => merge_beside(&mut body, replacement, "token"),
Ok(None) => {}
Err(response) => return Err(response),
}
}
tracing::info!(
provider = %provider.key,
user = %owner_id,
created = account.created,
linked = account.linking,
"oauth sign-in"
);
Ok(Success {
body,
token,
return_to,
delivery,
})
}
async fn claim_state(
state: &AppState,
provider: &str,
presented: &str,
) -> Result<Value, HttpResponse> {
let Some(state_tbl) = table(state, "oauth_state") else {
return Err(error(500, "no oauth_state resource"));
};
let hash = Authenticator::hash_link_token(presented);
let params = [Value::String(hash.clone()), Value::String(provider.into())];
let claim = format!(
"UPDATE {state_tbl} SET used_at = now() \
WHERE state_hash = $1 AND provider = $2 AND used_at IS NULL AND expires_at > now()"
);
let claimed = state
.db
.raw_json(&claim, ¶ms)
.await
.map_err(db_error)?
.get("rows_affected")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if claimed == 0 {
return Err(error(
400,
"that sign-in is no longer valid — please start again",
));
}
let lookup = format!(
"SELECT verifier, redirect_uri, return_to, token_delivery, \
link_user_id::text AS link_user_id \
FROM {state_tbl} WHERE state_hash = $1 AND provider = $2 LIMIT 1"
);
state
.db
.raw_json(&lookup, ¶ms)
.await
.map_err(db_error)?
.as_array()
.and_then(|rows| rows.first())
.cloned()
.ok_or_else(|| error(400, "that sign-in is no longer valid — please start again"))
}
struct Account {
user_id: Uuid,
created: bool,
linking: bool,
}
async fn resolve_account(
req: &HttpRequest,
state: &State<AppState>,
provider: &Provider,
profile: &Profile,
link_user_id: Option<Uuid>,
) -> Result<Account, HttpResponse> {
let provider_key = format!("{}:{}", provider.key, profile.id);
if let Some(existing) = find_connection(state, &provider_key).await? {
let owner = existing
.get("owner_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| error(500, "oauth_connection row has no owner"))?;
if link_user_id.is_some_and(|id| id != owner) {
return Err(error(
409,
format!(
"that {} account is already linked to a different user",
provider.label
),
));
}
return Ok(Account {
user_id: owner,
created: false,
linking: link_user_id.is_some(),
});
}
if let Some(user_id) = link_user_id {
return Ok(Account {
user_id,
created: false,
linking: true,
});
}
if state.app.config.oauth.link_by_verified_email && profile.email_verified {
if let Some(email) = profile.email.as_deref() {
if let Some(user_id) = find_user_by_identity(state, email).await? {
tracing::info!(
provider = %provider.key,
"matched a verified provider address to an existing account"
);
return Ok(Account {
user_id,
created: false,
linking: false,
});
}
}
}
create_account(req, state, provider, profile)
.await
.map(|user_id| Account {
user_id,
created: true,
linking: false,
})
}
async fn create_account(
req: &HttpRequest,
state: &State<AppState>,
provider: &Provider,
profile: &Profile,
) -> Result<Uuid, HttpResponse> {
let settings = &state.app.config.oauth;
if !state.app.config.auth.allow_registration {
return Err(error(
403,
"registration is disabled — ask for an invitation, then link this provider",
));
}
let Some(user_r) = state.app.resources.get("user") else {
return Err(error(500, "no user resource"));
};
let spec = auth_spec(state);
let (identity, placeholder) = match profile.email.as_deref().map(str::trim) {
Some(email) if !email.is_empty() => (email.to_lowercase(), false),
_ => (
format!("{}_{}@{PLACEHOLDER_DOMAIN}", provider.key, profile.id),
true,
),
};
let mut data = Map::new();
data.insert(spec.identity_field.clone(), json!(identity));
for (field, value) in [
(&settings.name_field, &profile.display_name),
(&settings.avatar_field, &profile.avatar_url),
] {
if let (false, Some(value)) = (field.trim().is_empty(), value) {
data.insert(field.trim().to_string(), json!(value));
}
}
if placeholder {
data.insert("email_placeholder".into(), json!(true));
}
if profile.email_verified && !placeholder {
data.insert(VERIFIED_AT_FIELD.into(), json!(Utc::now().to_rfc3339()));
}
if find_user_by_identity(state, &identity).await?.is_some() {
return Err(error(
409,
format!(
"an account already uses {identity} — sign in with it, then connect \
{} from your account settings",
provider.label
),
));
}
match crate::auth_routes::create_account(state, req, keep_declared(user_r, data)).await {
Ok((user_id, _)) => Ok(user_id),
Err(response) => Err(response),
}
}
async fn link_connection(
state: &State<AppState>,
provider: &Provider,
profile: &Profile,
account: &Account,
) -> Result<Uuid, HttpResponse> {
let Some(connection_r) = state.app.resources.get("oauth_connection") else {
return Err(error(500, "no oauth_connection resource"));
};
let provider_key = format!("{}:{}", provider.key, profile.id);
let mut row = Map::new();
row.insert("provider".into(), json!(provider.key));
row.insert("provider_user_id".into(), json!(profile.id));
row.insert("provider_key".into(), json!(provider_key));
row.insert("owner_id".into(), json!(account.user_id.to_string()));
row.insert(
"email".into(),
profile
.email
.clone()
.map(Value::from)
.unwrap_or(Value::Null),
);
row.insert("email_verified".into(), json!(profile.email_verified));
row.insert(
"display_name".into(),
profile
.display_name
.clone()
.map(Value::from)
.unwrap_or(Value::Null),
);
row.insert(
"avatar_url".into(),
profile
.avatar_url
.clone()
.map(Value::from)
.unwrap_or(Value::Null),
);
row.insert("last_login_at".into(), json!(Utc::now().to_rfc3339()));
let row = keep_declared(connection_r, row);
match find_connection(state, &provider_key).await? {
Some(existing) => {
let id = existing.get("id").and_then(|v| v.as_str()).unwrap_or("");
let owner = existing
.get("owner_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.ok_or_else(|| error(500, "oauth_connection row has no owner"))?;
let update: Map<String, Value> = row
.into_iter()
.filter(|(key, _)| OPTIONAL_CONNECTION_FIELDS.contains(&key.as_str()))
.collect();
if !update.is_empty() {
if let Ok(id) = Uuid::parse_str(id) {
if let Err(e) = state.db.update(connection_r, id, &update, &[]).await {
tracing::warn!(error = %e, "could not refresh an oauth connection");
}
}
}
Ok(owner)
}
None => {
match state.db.create(connection_r, &row).await {
Ok(_) => Ok(account.user_id),
Err(e) if is_conflict(&e) => {
let winner = find_connection(state, &provider_key)
.await?
.and_then(|row| {
row.get("owner_id")
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
})
.ok_or_else(|| error(500, "the connection could not be stored"))?;
if account.created && winner != account.user_id {
tracing::warn!("a concurrent sign-in created this account first; dropping the duplicate");
if let Some(user_r) = state.app.resources.get("user") {
let _ = state.db.delete(user_r, account.user_id, &[]).await;
}
}
Ok(winner)
}
Err(e) => Err(db_error(e)),
}
}
}
}
pub async fn unlink(req: HttpRequest, state: State<AppState>, path: Path<String>) -> HttpResponse {
let Some(principal) = state.resolve_principal(&req).await else {
return error(401, "authentication required");
};
let provider = path.into_inner().trim().to_lowercase();
let (Some(user_tbl), Some(connection_tbl)) =
(table(&state, "user"), table(&state, "oauth_connection"))
else {
return error(500, "missing user or oauth_connection resource");
};
let spec = auth_spec(&state);
let user_id = Value::String(principal.user_id.to_string());
let sql = format!(
"SELECT (u.{password} IS NOT NULL) AS has_password, \
(SELECT count(*) FROM {connection_tbl} c WHERE c.owner_id = u.id) AS connections, \
(SELECT count(*) FROM {connection_tbl} c \
WHERE c.owner_id = u.id AND c.provider = $2) AS matching \
FROM {user_tbl} u WHERE u.id = $1::uuid",
password = crate::auth_routes::quote(&spec.password_field),
);
let rows = match state
.db
.raw_json(&sql, &[user_id.clone(), Value::String(provider.clone())])
.await
{
Ok(rows) => rows,
Err(e) => return db_error(e),
};
let Some(row) = rows.as_array().and_then(|rows| rows.first()) else {
return error(404, "no such account");
};
let has_password = row.get("has_password").and_then(|v| v.as_bool()) == Some(true);
let connections = row.get("connections").and_then(|v| v.as_i64()).unwrap_or(0);
let matching = row.get("matching").and_then(|v| v.as_i64()).unwrap_or(0);
if matching == 0 {
return error(404, format!("no {provider} account is linked here"));
}
let credentials = connections + i64::from(has_password);
if credentials - matching < 1 {
return error(
409,
format!(
"{provider} is the only way into this account — set a password or link \
another provider before unlinking it"
),
);
}
let deleted =
format!("DELETE FROM {connection_tbl} WHERE owner_id = $1::uuid AND provider = $2");
match state
.db
.raw_json(&deleted, &[user_id, Value::String(provider.clone())])
.await
{
Ok(result) => {
let removed = result
.get("rows_affected")
.and_then(|v| v.as_u64())
.unwrap_or(0);
HttpResponse::Ok().json(&json!({
"provider": provider,
"removed": removed,
"credentials_left": credentials - removed as i64,
}))
}
Err(e) => db_error(e),
}
}
fn deliver(success: Success) -> HttpResponse {
let delivery = success.delivery.as_str();
if delivery == "json" {
return HttpResponse::Ok().json(&success.body);
}
let separator = match (delivery, success.return_to.contains('?')) {
("query", true) => '&',
("query", false) => '?',
_ => '#',
};
let target = format!(
"{}{separator}token={}",
success.return_to,
urlencoding(&success.token),
);
HttpResponse::Found()
.header("location", target)
.header("cache-control", "no-store")
.finish()
}
fn failure(state: &AppState, response: HttpResponse) -> HttpResponse {
let target = state.app.config.oauth.failure_redirect.trim();
if target.is_empty() || response.status().is_success() {
return response;
}
let separator = if target.contains('?') { '&' } else { '?' };
HttpResponse::Found()
.header(
"location",
format!(
"{target}{separator}oauth_error={}",
urlencoding(&format!("{}", response.status().as_u16()))
),
)
.header("cache-control", "no-store")
.finish()
}
fn resolve<'a>(state: &'a State<AppState>, key: &str) -> Result<&'a Provider, HttpResponse> {
let Some(oauth) = &state.oauth else {
return Err(error(404, "this app has no OAuth providers configured"));
};
oauth
.get(key)
.ok_or_else(|| error(404, format!("no `{key}` sign-in is configured here")))
}
fn requested_delivery(params: &std::collections::HashMap<String, String>) -> Option<String> {
let requested = params.get("token_delivery")?.trim().to_lowercase();
matches!(requested.as_str(), "fragment" | "query" | "json").then_some(requested)
}
fn return_to(state: &AppState, params: &std::collections::HashMap<String, String>) -> String {
let fallback = state.app.config.oauth.success_redirect.clone();
let Some(requested) = params.get("return_to").map(|s| s.trim()) else {
return fallback;
};
match requested.starts_with('/')
&& !requested.starts_with("//")
&& !requested.contains(['\\', '\n', '\r'])
{
true => requested.to_string(),
false => fallback,
}
}
pub fn callback_base(state: &AppState) -> String {
format!(
"{}{}/auth/oauth",
state.app.config.server.public_origin(),
state.app.config.server.base_path.trim_end_matches('/'),
)
}
fn keep_declared(
resource: &apiplant_core::schema::Resource,
data: Map<String, Value>,
) -> Map<String, Value> {
data.into_iter()
.filter(|(key, _)| resource.fields.contains_key(key))
.collect()
}
async fn find_connection(
state: &AppState,
provider_key: &str,
) -> Result<Option<Value>, HttpResponse> {
let Some(connection_tbl) = table(state, "oauth_connection") else {
return Err(error(500, "no oauth_connection resource"));
};
let sql = format!(
"SELECT id::text AS id, owner_id::text AS owner_id FROM {connection_tbl} \
WHERE provider_key = $1 LIMIT 1"
);
Ok(state
.db
.raw_json(&sql, &[Value::String(provider_key.to_string())])
.await
.map_err(db_error)?
.as_array()
.and_then(|rows| rows.first())
.cloned())
}
async fn find_user_by_identity(
state: &AppState,
identity: &str,
) -> Result<Option<Uuid>, HttpResponse> {
let Some(user_tbl) = table(state, "user") else {
return Err(error(500, "no user resource"));
};
let spec = auth_spec(state);
let sql = format!(
"SELECT id::text AS id FROM {user_tbl} WHERE lower({identity_field}) = lower($1) LIMIT 1",
identity_field = crate::auth_routes::quote(&spec.identity_field),
);
Ok(state
.db
.raw_json(&sql, &[Value::String(identity.to_string())])
.await
.map_err(db_error)?
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row.get("id"))
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok()))
}
async fn read_user(state: &AppState, user_id: Uuid) -> Result<Value, HttpResponse> {
let Some(user_r) = state.app.resources.get("user") else {
return Err(error(500, "no user resource"));
};
state
.db
.get(user_r, user_id, &[])
.await
.map_err(db_error)?
.ok_or_else(|| error(500, "the account disappeared mid-sign-in"))
}
fn verified_in(state: &AppState, user: &Value) -> bool {
match state
.app
.resources
.get("user")
.is_some_and(|r| r.fields.contains_key(VERIFIED_AT_FIELD))
{
true => !matches!(user.get(VERIFIED_AT_FIELD), None | Some(Value::Null)),
false => true,
}
}
async fn sweep_expired(state: &AppState) {
let Some(state_tbl) = table(state, "oauth_state") else {
return;
};
let sql = format!("DELETE FROM {state_tbl} WHERE expires_at < now() - interval '1 day'");
if let Err(error) = state.db.raw_json(&sql, &[]).await {
tracing::warn!(%error, "could not sweep expired oauth states");
}
}
fn is_conflict(error: &apiplant_db::Error) -> bool {
let text = error.to_string().to_lowercase();
text.contains("unique") || text.contains("duplicate key")
}
fn oauth_error(error: apiplant_oauth::Error) -> HttpResponse {
use apiplant_oauth::Error;
match error {
Error::NotConfigured(message) => crate::response::error(404, message),
Error::Misconfigured(message) => {
tracing::error!(detail = %message, "oauth provider is misconfigured");
crate::response::error(500, "this sign-in is misconfigured")
}
Error::Refused {
ref provider,
stage,
ref detail,
} => {
tracing::error!(%provider, stage, %detail, "oauth provider refused");
crate::response::error(
400,
format!("{provider} did not complete the sign-in — please try again"),
)
}
Error::Unreachable { ref provider, .. } => {
let provider = provider.clone();
crate::telemetry::record_error("oauth_unreachable", &error);
tracing::error!(%error, "oauth provider could not be reached");
crate::response::error(
502,
format!("{provider} could not be reached — please try again"),
)
}
Error::Unreadable(_) => {
tracing::error!(%error, "oauth provider sent an unreadable reply");
crate::response::error(
502,
"that sign-in could not be completed — please try again",
)
}
}
}
fn merge_beside(response: &mut Value, replacement: Value, reserved: &str) {
let (Some(target), Value::Object(fields)) = (response.as_object_mut(), replacement) else {
return;
};
for (key, value) in fields {
if key != reserved {
target.insert(key, value);
}
}
}
fn urlencoding(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}