| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
use crate::api::ApiError; |
| 9 |
use crate::{db, Shared}; |
| 10 |
use axum::extract::State; |
| 11 |
use axum::http::{HeaderMap, StatusCode}; |
| 12 |
use axum::Json; |
| 13 |
use oxy_core::cert::{certify_challenge, Certificate, SignedCertificate}; |
| 14 |
use oxy_core::identity::{verify_challenge, Identity, Scope, Signature}; |
| 15 |
use serde::{Deserialize, Serialize}; |
| 16 |
|
| 17 |
|
| 18 |
pub const LIFETIME_MS: i64 = 10 * 60 * 1000; |
| 19 |
|
| 20 |
const SKEW_MS: i64 = 30 * 1000; |
| 21 |
|
| 22 |
|
| 23 |
pub fn audience(base_url: &str) -> String { |
| 24 |
format!("{}/keyless", base_url.trim_end_matches('/')) |
| 25 |
} |
| 26 |
|
| 27 |
#[derive(Deserialize)] |
| 28 |
pub struct CertifyRequest { |
| 29 |
|
| 30 |
pub identity: String, |
| 31 |
|
| 32 |
pub proof: String, |
| 33 |
|
| 34 |
pub scope: String, |
| 35 |
} |
| 36 |
|
| 37 |
#[derive(Serialize)] |
| 38 |
pub struct CertifyResponse { |
| 39 |
|
| 40 |
pub certificate: String, |
| 41 |
pub subject: String, |
| 42 |
pub expires_ms: i64, |
| 43 |
} |
| 44 |
|
| 45 |
|
| 46 |
struct Proven { |
| 47 |
subject: String, |
| 48 |
claims: Vec<(String, String)>, |
| 49 |
|
| 50 |
until_ms: i64, |
| 51 |
target: String, |
| 52 |
} |
| 53 |
|
| 54 |
fn bad(status: StatusCode, why: impl Into<String>) -> ApiError { |
| 55 |
ApiError(status, why.into()) |
| 56 |
} |
| 57 |
|
| 58 |
|
| 59 |
async fn job_proof(app: &Shared, token: &str) -> Result<Proven, ApiError> { |
| 60 |
let invalid = || bad(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)"); |
| 61 |
let hash = crate::auth::token_hash(token); |
| 62 |
let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS) |
| 63 |
.await |
| 64 |
.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 65 |
.ok_or_else(invalid)?; |
| 66 |
let run = db::flow::run_by_id(&app.db, job.run_id) |
| 67 |
.await |
| 68 |
.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 69 |
.ok_or_else(invalid)?; |
| 70 |
let repo = db::repos::get_by_id(&app.db, run.repo_id) |
| 71 |
.await |
| 72 |
.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
| 73 |
.ok_or_else(invalid)?; |
| 74 |
let mut claims = vec![ |
| 75 |
("repository".to_string(), format!("{}/{}", repo.owner, repo.name)), |
| 76 |
("bookmark".to_string(), run.bookmark.clone()), |
| 77 |
("run".to_string(), run.number.to_string()), |
| 78 |
("job".to_string(), job.name.clone()), |
| 79 |
("runner".to_string(), job.runner.clone()), |
| 80 |
]; |
| 81 |
if !job.environment.is_empty() && !job.approved_by.is_empty() { |
| 82 |
claims.push(("environment".to_string(), job.environment.clone())); |
| 83 |
claims.push(("approved_by".to_string(), job.approved_by.clone())); |
| 84 |
} |
| 85 |
Ok(Proven { |
| 86 |
subject: format!("repo:{}/{}:bookmark:{}", repo.owner, repo.name, run.bookmark), |
| 87 |
claims, |
| 88 |
until_ms: job.started_ms.unwrap_or(0) + crate::flow::JOB_TOKEN_MAX_AGE_MS, |
| 89 |
target: format!("{}/{}", repo.owner, repo.name), |
| 90 |
}) |
| 91 |
} |
| 92 |
|
| 93 |
|
| 94 |
async fn oidc_proof(app: &Shared, token: &str, now: i64) -> Result<Proven, ApiError> { |
| 95 |
let issuer = app.oidc().await.map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 96 |
let aud = audience(app.config.base_url()); |
| 97 |
let claims = issuer |
| 98 |
.verify(token, &aud, now) |
| 99 |
.ok_or_else(|| bad(StatusCode::UNAUTHORIZED, format!("the token is not one of this hub's, for audience {aud}, still valid")))?; |
| 100 |
let subject = claims["sub"].as_str().unwrap_or("").to_string(); |
| 101 |
if subject.is_empty() { |
| 102 |
return Err(bad(StatusCode::UNAUTHORIZED, "the token names no subject")); |
| 103 |
} |
| 104 |
let keep = ["repository", "bookmark", "run_number", "job", "runner", "environment", "approved_by"]; |
| 105 |
let mut out = Vec::new(); |
| 106 |
for k in keep { |
| 107 |
match &claims[k] { |
| 108 |
serde_json::Value::String(s) => out.push((k.to_string(), s.clone())), |
| 109 |
serde_json::Value::Number(n) => out.push((k.to_string(), n.to_string())), |
| 110 |
_ => {} |
| 111 |
} |
| 112 |
} |
| 113 |
Ok(Proven { |
| 114 |
target: claims["repository"].as_str().unwrap_or(&subject).to_string(), |
| 115 |
subject, |
| 116 |
claims: out, |
| 117 |
until_ms: claims["exp"].as_i64().unwrap_or(0) * 1000, |
| 118 |
}) |
| 119 |
} |
| 120 |
|
| 121 |
|
| 122 |
|
| 123 |
pub async fn certify( |
| 124 |
State(app): State<Shared>, |
| 125 |
headers: HeaderMap, |
| 126 |
Json(req): Json<CertifyRequest>, |
| 127 |
) -> Result<Json<CertifyResponse>, ApiError> { |
| 128 |
let token = oxy_server::bearer_token(&headers) |
| 129 |
.ok_or_else(|| bad(StatusCode::UNAUTHORIZED, "ask with a job token or one of this hub's OIDC tokens as the bearer"))? |
| 130 |
.to_string(); |
| 131 |
let now = db::now_ms(); |
| 132 |
let proven = if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) { |
| 133 |
job_proof(&app, &token).await? |
| 134 |
} else { |
| 135 |
oidc_proof(&app, &token, now).await? |
| 136 |
}; |
| 137 |
let decode = |what: &str, b64: &str| { |
| 138 |
oxy_core::dsse::base64_decode(b64.trim()).ok_or_else(|| bad(StatusCode::BAD_REQUEST, format!("{what} is not base64"))) |
| 139 |
}; |
| 140 |
let identity = |
| 141 |
Identity::decode(&decode("identity", &req.identity)?).map_err(|e| bad(StatusCode::BAD_REQUEST, format!("identity: {e}")))?; |
| 142 |
let proof = oxy_core::cbor::decode(&decode("proof", &req.proof)?) |
| 143 |
.map_err(|e| e.to_string()) |
| 144 |
.and_then(|v| Signature::from_value(&v).map_err(|e| e.to_string())) |
| 145 |
.map_err(|e| bad(StatusCode::BAD_REQUEST, format!("proof: {e}")))?; |
| 146 |
verify_challenge(&identity, &proof, &certify_challenge(&token)) |
| 147 |
.map_err(|e| bad(StatusCode::UNAUTHORIZED, format!("the proof does not show this key's holder asked: {e}")))?; |
| 148 |
let scope = Scope::parse(&req.scope).map_err(|e| bad(StatusCode::BAD_REQUEST, e))?; |
| 149 |
let expires = (now + LIFETIME_MS).min(proven.until_ms); |
| 150 |
if expires <= now { |
| 151 |
return Err(bad(StatusCode::UNAUTHORIZED, "the credential is about to expire: ask with a fresh one")); |
| 152 |
} |
| 153 |
let (key, secret) = |
| 154 |
crate::keys::load_secret(&app.config.keys_dir()).map_err(|e| bad(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
| 155 |
let cert = Certificate { |
| 156 |
key: identity, |
| 157 |
issuer: key, |
| 158 |
subject: proven.subject.clone(), |
| 159 |
claims: proven.claims, |
| 160 |
scope, |
| 161 |
not_before: (now - SKEW_MS) as u64, |
| 162 |
expires: expires as u64, |
| 163 |
}; |
| 164 |
let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret); |
| 165 |
let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires); |
| 166 |
db::accounts::audit(&app.db, None, "keyless.certified", &proven.target, &detail).await.ok(); |
| 167 |
Ok(Json(CertifyResponse { certificate: oxy_core::dsse::base64(&signed.encode()), subject: proven.subject, expires_ms: expires })) |
| 168 |
} |