Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions crates/alien-bindings/src/providers/artifact_registry/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,15 @@ impl ArtifactRegistry for LocalArtifactRegistry {
}

fn upstream_repository_prefix(&self) -> String {
// The embedded local registry accepts two-segment repo paths (e.g.,
// "namespace/repo"). We use "artifacts/default" as the canonical prefix
// — this matches what the CLI hardcodes in dev mode and what the proxy
// routing table uses to route pushes to this local registry.
"artifacts/default".to_string()
// The binding name is the routing prefix; the next path segment is the
// project namespace (`artifacts/{project_id}` in platform mode,
// `artifacts/default` in dev mode). Returning the bare binding name
// here — instead of the legacy `"artifacts/default"` — lets the proxy
// routing table correctly identify both modes' pushes as local and
// extract the right project_id for downstream authz/scope checks.
// Matches the per-cloud convention documented in
// `internal-docs/alien/02-manager/02-releases.md`.
self.binding_name.clone()
}

async fn create_repository(&self, repo_name: &str) -> Result<RepositoryResponse> {
Expand Down
271 changes: 270 additions & 1 deletion crates/alien-cli/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ mod oauth_flow {
const SERVICE: &str = "alien-cli";
const ACCESS_USER: &str = "access_token";
const REFRESH_USER: &str = "refresh_token";
/// Manager-scoped Platform JWT minted on `alien login`. Different audience
/// from the user OAuth access_token, so the manager can verify it locally
/// against its configured public key without a /v1/whoami forward.
const MANAGER_TOKEN_USER: &str = "manager_token";
const DEFAULT_BASE: &str = "https://api.alien.dev";
const CLI_CLIENT_ID: &str = "alien-cli";

Expand Down Expand Up @@ -280,6 +284,7 @@ mod oauth_flow {
pub fn logout() {
let _ = Entry::new(SERVICE, ACCESS_USER).and_then(|e| e.delete_password());
let _ = Entry::new(SERVICE, REFRESH_USER).and_then(|e| e.delete_password());
let _ = Entry::new(SERVICE, MANAGER_TOKEN_USER).and_then(|e| e.delete_password());
let _ = std::fs::remove_file(cfg_path());
with_cache(|cache| cache.clear());
}
Expand Down Expand Up @@ -472,6 +477,173 @@ mod oauth_flow {
Ok(())
}

/// Exchanges a user OAuth JWT for a project-scoped registry-push JWT via
/// `POST {base}/v1/managers/{manager_id}/token`. The result is the only
/// credential the manager accepts on `/v2/...` pushes (single scope tuple,
/// `managerId`-bound, audience `alien:manager:registry-push`).
pub async fn mint_registry_push_token(
client: &Client,
base: &str,
user_jwt: &str,
workspace: &str,
manager_id: &str,
project_id: &str,
) -> Result<String> {
#[derive(serde::Deserialize)]
struct ExchangeResponse {
#[serde(rename = "accessToken")]
access_token: String,
}

#[derive(serde::Serialize)]
struct ExchangeBody<'a> {
purpose: &'a str,
project: &'a str,
}

// URL-encode the path segment to match the established convention in
// the rest of the crate (see `execution_context.rs::resolve_url` and
// the gcp / aws clients). Manager IDs are ULIDs today and contain no
// URL-special characters, but encoding here keeps the call site
// correct-by-construction rather than correct-by-coincidence — a
// future change to the ID format won't silently mis-route the request.
let response = client
.post(format!(
"{}/v1/managers/{}/token",
base,
urlencoding::encode(manager_id)
))
.query(&[("workspace", workspace)])
Comment thread
greptile-apps[bot] marked this conversation as resolved.
.header("Authorization", format!("Bearer {}", user_jwt))
.json(&ExchangeBody {
purpose: "registry-push",
project: project_id,
})
.send()
.await
.into_alien_error()
.context(ErrorData::AuthenticationFailed {
reason: "Failed to call manager-token exchange endpoint".to_string(),
})?;

if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(AlienError::new(ErrorData::AuthenticationFailed {
reason: format!(
"manager-token exchange returned status {}: {}",
status, body
),
}));
}

let body: ExchangeResponse = response.json().await.into_alien_error().context(
ErrorData::AuthenticationFailed {
reason: "Failed to parse manager-token exchange response".to_string(),
},
)?;

Ok(body.access_token)
}

pub fn store_manager_token(token: &str) -> Result<()> {
Entry::new(SERVICE, MANAGER_TOKEN_USER)
.into_alien_error()
.context(ErrorData::AuthenticationFailed {
reason: "Failed to create manager token keyring entry".to_string(),
})?
.set_password(token)
.into_alien_error()
.context(ErrorData::AuthenticationFailed {
reason: "Failed to store manager token".to_string(),
})?;
Ok(())
}

pub fn load_manager_token() -> Option<String> {
Entry::new(SERVICE, MANAGER_TOKEN_USER)
.ok()?
.get_password()
.ok()
}

/// Checks `managerId` equality and that at least one `scopes` entry ends
/// with `/{project_id}`. Project IDs are globally-unique `prj_*` ULIDs, so
/// the suffix check is equivalent to an exact match on the project segment
/// without needing `workspace_id` (the CLI doesn't have it locally).
/// Returns false on any decode failure — caller re-mints, avoiding a
/// guaranteed 401 on the wire.
fn token_matches_target(jwt: &str, manager_id: &str, project_id: &str) -> bool {
let Some(payload_b64) = jwt.split('.').nth(1) else {
return false;
};
let Ok(payload_bytes) = URL_SAFE_NO_PAD.decode(payload_b64) else {
return false;
};
let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) else {
return false;
};
let mid_ok = claims
.get("managerId")
.and_then(|v| v.as_str())
.is_some_and(|m| m == manager_id);
let suffix = format!("/{}", project_id);
let scope_ok = claims
.get("scopes")
.and_then(|v| v.as_array())
.is_some_and(|arr| {
arr.iter()
.filter_map(|s| s.as_str())
.any(|s| s.ends_with(&suffix))
});
mid_ok && scope_ok
}

/// Returns a valid registry-push JWT for the (manager, project) target.
/// Reuses the keyring-cached JWT when it matches; common re-mint case is
/// the user switching projects between `alien release` invocations.
pub async fn get_or_mint_registry_push_token(
http: &AuthHttp,
workspace: &str,
manager_id: &str,
project_id: &str,
) -> Result<String> {
if let Some(existing) = load_manager_token() {
if !token_expired(&existing, 30)
&& token_matches_target(&existing, manager_id, project_id)
{
return Ok(existing);
}
}

let user_jwt = http.bearer_token.as_deref().ok_or_else(|| {
AlienError::new(ErrorData::AuthenticationFailed {
reason: "No user OAuth JWT available to exchange for manager token".to_string(),
})
})?;

let minted = mint_registry_push_token(
&http.client,
&http.base_url,
user_jwt,
workspace,
manager_id,
project_id,
)
.await?;
// Cache write is best-effort. The mint succeeded, so we hold a valid
// token in memory and the caller can complete the operation. A failed
// keyring write just means we'll re-mint on the next invocation —
// strictly slower, never wrong. This matches the cache-failure pattern
// documented in `alien/CLAUDE.md` ("When `warn!` Is Appropriate").
if let Err(e) = store_manager_token(&minted) {
tracing::warn!(
"Failed to cache manager token in keyring (will re-mint next run): {e}"
);
}
Ok(minted)
}

async fn refresh_token(base: &str, refresh: &str) -> Result<TokenResponse> {
let auth_url = AuthUrl::new(format!("{}/auth/oauth2/authorize", base))
.into_alien_error()
Expand Down Expand Up @@ -868,8 +1040,105 @@ mod oauth_flow {
}
}
}

#[cfg(test)]
mod tests {
//! Tests for cache-reuse logic. `token_matches_target` decides whether
//! a cached manager-scoped JWT can be reused for a given (manager,
//! project) target without re-minting. Failure modes:
//!
//! * A token minted for a *different* manager being reused (would
//! cause a managerId-binding rejection at the manager's verifier
//! and an avoidable round-trip — but more importantly, would
//! surface as a confusing 401 rather than triggering a fresh mint).
//! * A token minted for a *different* project being reused (would
//! fail at the scope check on the OCI push path).
//! * A malformed JWT being treated as "matches" (would skip the
//! re-mint and definitely 401 at the manager).
//!
//! All failure cases must return `false` so the caller re-mints.
use super::*;

const MANAGER_ID: &str = "mgr_xaji0y6dpbhsahxthv3a3zmf8mdd";
const OTHER_MANAGER_ID: &str = "mgr_other";
const PROJECT_ID: &str = "prj_nisyo047zb0ourgk7wguij040q1x";
const OTHER_PROJECT_ID: &str = "prj_other";

/// Build a fake JWT (header.payload.sig) where the payload is the
/// given JSON object base64-url-encoded without padding. The header
/// and signature segments are placeholders — `token_matches_target`
/// only ever decodes the payload, so this is enough to exercise the
/// matching logic.
fn fake_jwt(payload: &str) -> String {
format!(
"header.{}.sig",
URL_SAFE_NO_PAD.encode(payload.as_bytes())
)
}

#[test]
fn matches_when_manager_id_and_project_scope_align() {
let jwt = fake_jwt(&format!(
r#"{{"managerId":"{MANAGER_ID}","scopes":["ws_abc/{PROJECT_ID}"]}}"#
));
assert!(token_matches_target(&jwt, MANAGER_ID, PROJECT_ID));
}

#[test]
fn rejects_when_manager_id_mismatches() {
let jwt = fake_jwt(&format!(
r#"{{"managerId":"{OTHER_MANAGER_ID}","scopes":["ws_abc/{PROJECT_ID}"]}}"#
));
assert!(!token_matches_target(&jwt, MANAGER_ID, PROJECT_ID));
}

#[test]
fn rejects_when_project_scope_mismatches() {
let jwt = fake_jwt(&format!(
r#"{{"managerId":"{MANAGER_ID}","scopes":["ws_abc/{OTHER_PROJECT_ID}"]}}"#
));
assert!(!token_matches_target(&jwt, MANAGER_ID, PROJECT_ID));
}

#[test]
fn rejects_when_scopes_claim_is_missing() {
// Defense in depth: even if managerId matches, a token with no
// scope can't push to the target project, so don't reuse it.
let jwt = fake_jwt(&format!(r#"{{"managerId":"{MANAGER_ID}"}}"#));
assert!(!token_matches_target(&jwt, MANAGER_ID, PROJECT_ID));
}

#[test]
fn rejects_when_manager_id_claim_is_missing() {
let jwt = fake_jwt(&format!(
r#"{{"scopes":["ws_abc/{PROJECT_ID}"]}}"#
));
assert!(!token_matches_target(&jwt, MANAGER_ID, PROJECT_ID));
}

#[test]
fn rejects_malformed_jwt() {
// Must NOT panic on garbage — re-minting is the safe response.
assert!(!token_matches_target("not.a.jwt", MANAGER_ID, PROJECT_ID));
assert!(!token_matches_target("only-one-segment", MANAGER_ID, PROJECT_ID));
assert!(!token_matches_target("", MANAGER_ID, PROJECT_ID));
}

#[test]
fn rejects_jwt_with_non_array_scopes() {
// A buggy producer that serialized `scopes` as a string instead
// of an array must not be treated as a valid cached token.
let jwt = fake_jwt(&format!(
r#"{{"managerId":"{MANAGER_ID}","scopes":"ws_abc/{PROJECT_ID}"}}"#
));
assert!(!token_matches_target(&jwt, MANAGER_ID, PROJECT_ID));
}
}
}

// Re-export platform-only functions
#[cfg(feature = "platform")]
pub use oauth_flow::{force_login, get_auth_http, logout, store_tokens, try_bearer_client};
pub use oauth_flow::{
force_login, get_auth_http, get_or_mint_registry_push_token, logout, store_tokens,
try_bearer_client,
};
41 changes: 38 additions & 3 deletions crates/alien-cli/src/commands/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,44 @@ pub enum CommandsAction {

/// Execute commands task — works in all execution modes.
pub async fn commands_task(args: CommandsArgs, ctx: ExecutionMode) -> Result<()> {
let manager = ctx.server_sdk_client()?;
let manager_url = ctx.manager_url();
// `server_sdk_client()` errors in Platform mode because the manager
// URL + auth aren't known until the project is resolved. For Platform
// mode we resolve the manager (which mints the per-project JWT and
// builds an authenticated client) before doing anything else. For
// Standalone/Dev the existing code path is unchanged — those modes
// already carry the manager URL + API key statically on ExecutionMode.
let (manager, manager_url, auth_token) = {
#[cfg(feature = "platform")]
if matches!(&ctx, ExecutionMode::Platform { .. }) {
// Platform mode: resolve the project (from --project override or
// local config), then ask the platform API for the manager that
// hosts it. `resolve_manager` returns a ManagerContext whose
// `client` is already authenticated with the minted manager JWT
// and whose `auth_token` carries that same JWT for the separate
// CommandsClient HTTP call below.
let (_, project_link) = ctx.resolve_project(None, true).await?;
let mgr_ctx = ctx
.resolve_manager(&project_link.project_id, "aws")
.await?;
(
mgr_ctx.client,
mgr_ctx.manager_url,
mgr_ctx.auth_token.unwrap_or_default(),
)
} else {
(
ctx.server_sdk_client()?,
ctx.manager_url(),
ctx.auth_token().unwrap_or_default().to_string(),
)
}
#[cfg(not(feature = "platform"))]
(
ctx.server_sdk_client()?,
ctx.manager_url(),
ctx.auth_token().unwrap_or_default().to_string(),
)
};

match args.action {
CommandsAction::Invoke {
Expand All @@ -75,7 +111,6 @@ pub async fn commands_task(args: CommandsArgs, ctx: ExecutionMode) -> Result<()>
timeout,
} => {
let is_dev = ctx.is_dev();
let auth_token = ctx.auth_token().unwrap_or_default().to_string();
invoke_command(
&manager,
&manager_url,
Expand Down
Loading
Loading