Skip to content

Commit d21f229

Browse files
committed
feat: add TPK-based JWT generation for Gitlab environments
1 parent 86bc985 commit d21f229

1 file changed

Lines changed: 266 additions & 8 deletions

File tree

src/auth/gitlab/mod.rs

Lines changed: 266 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,21 @@
1-
//! Authentication for Gitlab Pipelines
1+
//! Authentication for GitLab CI pipelines.
2+
//!
3+
//! Token resolution order:
4+
//!
5+
//! 1. **`AMPLIFY_ID_TOKEN`** – an OIDC ID token issued by GitLab itself.
6+
//! This token is automatically configured in Amplify's runner component
7+
//! and should be preferred when in use.
8+
//! 2. **`TRUSTED_PRIVATE_KEY`** – a PEM-encoded private key supplied by the
9+
//! user that is configured in Amplify. The runner signs its own JWT and
10+
//! includes a set of GitLab predefined CI/CD variables as claims so that
11+
//! the Amplify API can identify the pipeline and project.
212
3-
use color_eyre::eyre::{eyre, Result};
13+
use color_eyre::eyre::{Result, WrapErr};
14+
use serde::{Deserialize, Serialize};
15+
16+
use crate::auth::tpk::{TpkJwt, DEFAULT_TOKEN_TTL_SECS};
17+
18+
// ─── auth provider ───────────────────────────────────────────────────────────
419

520
#[derive(Debug, Clone)]
621
pub(crate) struct GitlabAuth {
@@ -12,15 +27,258 @@ impl GitlabAuth {
1227
Ok(GitlabAuth { jwt: None })
1328
}
1429

30+
/// Return a bearer token that identifies this pipeline run to the Amplify API.
1531
pub async fn get_token(&mut self) -> Result<String> {
16-
// GitLab generates JWTs for specific audiences within the pipeline
17-
// configuration itself, so all we have to do is pull the env variable
18-
// https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html
1932
if let Ok(token) = std::env::var("AMPLIFY_ID_TOKEN") {
20-
self.jwt = Some(token);
21-
return Ok(self.jwt.clone().unwrap());
33+
self.jwt = Some(token.clone());
34+
return Ok(token);
35+
}
36+
37+
if let Some(signer) =
38+
TpkJwt::from_env().wrap_err("Failed to load TRUSTED_PRIVATE_KEY for GitLab TPK JWT")?
39+
{
40+
let claims = GitlabTpkClaims::from_env()
41+
.wrap_err("Failed to read required GitLab CI variables for TPK JWT")?;
42+
let token = signer
43+
.create_token(claims, DEFAULT_TOKEN_TTL_SECS)
44+
.wrap_err("Failed to sign GitLab TPK JWT")?;
45+
self.jwt = Some(token.clone());
46+
return Ok(token);
2247
}
2348

24-
Err(eyre!("Failed to locate an ID Token from Gitlab."))
49+
Err(color_eyre::eyre::eyre!(
50+
"No GitLab ID token found. \
51+
Either use the amplify-security/components/runner component in \
52+
your `.gitlab-ci.yml`, or create a keypair in Amplify and \
53+
configure `TRUSTED_PRIVATE_KEY` to the private key in your CI \
54+
environment variables."
55+
))
56+
}
57+
}
58+
59+
/// JWT payload for the Trusted Public Key fallback path.
60+
///
61+
/// The field names match the claim names used in real GitLab OIDC ID tokens so
62+
/// that the Amplify API can handle both token kinds uniformly.
63+
#[derive(Debug, Serialize, Deserialize)]
64+
struct GitlabTpkClaims {
65+
/// URL of the GitLab instance (`CI_SERVER_URL`).
66+
/// e.g. `"https://gitlab.com"` or `"https://gitlab.example.com:8080"`.
67+
ci_server_url: String,
68+
69+
/// Instance-level pipeline ID (`CI_PIPELINE_ID`).
70+
pipeline_id: String,
71+
72+
/// Instance-level project ID (`CI_PROJECT_ID`).
73+
project_id: String,
74+
75+
/// Namespace + project path (`CI_PROJECT_PATH`), e.g. `"my-group/my-project"`.
76+
project_path: String,
77+
78+
/// The branch or tag name (`CI_COMMIT_REF_NAME`).
79+
/// Renamed to `ref` to match the GitLab OIDC token schema.
80+
#[serde(rename = "ref")]
81+
git_ref: String,
82+
83+
/// Instance-level job ID (`CI_JOB_ID`).
84+
job_id: String,
85+
86+
/// Full commit SHA (`CI_COMMIT_SHA`).
87+
sha: String,
88+
}
89+
90+
impl GitlabTpkClaims {
91+
/// Populate claims from the current process environment.
92+
///
93+
/// All fields are required. Every variable listed here is a predefined
94+
/// GitLab CI/CD variable that is always present in any GitLab CI job.
95+
fn from_env() -> Result<Self> {
96+
Ok(Self {
97+
ci_server_url: std::env::var("CI_SERVER_URL")
98+
.wrap_err("Expected CI_SERVER_URL to be set, but it wasn't!")?,
99+
pipeline_id: std::env::var("CI_PIPELINE_ID")
100+
.wrap_err("Expected CI_PIPELINE_ID to be set, but it wasn't!")?,
101+
project_id: std::env::var("CI_PROJECT_ID")
102+
.wrap_err("Expected CI_PROJECT_ID to be set, but it wasn't!")?,
103+
project_path: std::env::var("CI_PROJECT_PATH")
104+
.wrap_err("Expected CI_PROJECT_PATH to be set, but it wasn't!")?,
105+
git_ref: std::env::var("CI_COMMIT_REF_NAME")
106+
.wrap_err("Expected CI_COMMIT_REF_NAME to be set, but it wasn't!")?,
107+
job_id: std::env::var("CI_JOB_ID")
108+
.wrap_err("Expected CI_JOB_ID to be set, but it wasn't!")?,
109+
sha: std::env::var("CI_COMMIT_SHA")
110+
.wrap_err("Expected CI_COMMIT_SHA to be set, but it wasn't!")?,
111+
})
112+
}
113+
}
114+
115+
#[cfg(test)]
116+
mod tests {
117+
use super::*;
118+
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
119+
use std::sync::Mutex;
120+
121+
// Serialise all environment-variable mutations to prevent data races
122+
// between tests that run in parallel within the same process.
123+
static ENV_MUTEX: Mutex<()> = Mutex::new(());
124+
125+
const TEST_PRIVATE_KEY_PEM: &str = include_str!("../../../ecdsa-p521-local.private.pem");
126+
const TEST_PUBLIC_KEY_PEM: &str = include_str!("../../../ecdsa-p521-local.public.pem");
127+
128+
fn set_all_gitlab_vars() {
129+
std::env::set_var("CI_SERVER_URL", "https://gitlab.example.com");
130+
std::env::set_var("CI_PIPELINE_ID", "1001");
131+
std::env::set_var("CI_PROJECT_ID", "42");
132+
std::env::set_var("CI_PROJECT_PATH", "my-group/my-project");
133+
std::env::set_var("CI_COMMIT_REF_NAME", "main");
134+
std::env::set_var("CI_JOB_ID", "9999");
135+
std::env::set_var("CI_COMMIT_SHA", "abc123def456");
136+
}
137+
138+
fn clear_all_vars() {
139+
std::env::remove_var("AMPLIFY_ID_TOKEN");
140+
std::env::remove_var("TRUSTED_PRIVATE_KEY");
141+
std::env::remove_var("CI_SERVER_URL");
142+
std::env::remove_var("CI_PIPELINE_ID");
143+
std::env::remove_var("CI_PROJECT_ID");
144+
std::env::remove_var("CI_PROJECT_PATH");
145+
std::env::remove_var("CI_COMMIT_REF_NAME");
146+
std::env::remove_var("CI_JOB_ID");
147+
std::env::remove_var("CI_COMMIT_SHA");
148+
}
149+
150+
fn make_validation() -> Validation {
151+
let mut v = Validation::new(Algorithm::ES512);
152+
v.set_audience(&[crate::auth::tpk::DEFAULT_AUDIENCE]);
153+
v
154+
}
155+
156+
// AMPLIFY_ID_TOKEN usage (from runner component)
157+
158+
#[tokio::test]
159+
async fn test_amplify_id_token_is_returned_directly() {
160+
let _lock = ENV_MUTEX.lock().unwrap();
161+
clear_all_vars();
162+
std::env::set_var("AMPLIFY_ID_TOKEN", "gitlab.issued.token");
163+
164+
let mut auth = GitlabAuth::new().unwrap();
165+
let token = auth.get_token().await.unwrap();
166+
167+
assert_eq!(token, "gitlab.issued.token");
168+
}
169+
170+
#[tokio::test]
171+
async fn test_amplify_id_token_is_cached_on_auth_struct() {
172+
let _lock = ENV_MUTEX.lock().unwrap();
173+
clear_all_vars();
174+
std::env::set_var("AMPLIFY_ID_TOKEN", "cached.token");
175+
176+
let mut auth = GitlabAuth::new().unwrap();
177+
auth.get_token().await.unwrap();
178+
179+
assert_eq!(auth.jwt.as_deref(), Some("cached.token"));
180+
}
181+
182+
// TPK Fallback
183+
184+
#[tokio::test]
185+
async fn test_tpk_fallback_produces_valid_jwt() {
186+
let _lock = ENV_MUTEX.lock().unwrap();
187+
clear_all_vars();
188+
set_all_gitlab_vars();
189+
std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM);
190+
191+
let mut auth = GitlabAuth::new().unwrap();
192+
let token = auth.get_token().await.unwrap();
193+
194+
let decoding_key = DecodingKey::from_ec_pem(TEST_PUBLIC_KEY_PEM.as_bytes()).unwrap();
195+
let decoded =
196+
decode::<serde_json::Value>(&token, &decoding_key, &make_validation()).unwrap();
197+
let claims = decoded.claims;
198+
199+
assert_eq!(claims["ci_server_url"], "https://gitlab.example.com");
200+
assert_eq!(claims["pipeline_id"], "1001");
201+
assert_eq!(claims["project_id"], "42");
202+
assert_eq!(claims["project_path"], "my-group/my-project");
203+
assert_eq!(claims["ref"], "main");
204+
assert_eq!(claims["job_id"], "9999");
205+
assert_eq!(claims["sha"], "abc123def456");
206+
}
207+
208+
#[tokio::test]
209+
async fn test_tpk_fallback_uses_correct_issuer_and_audience() {
210+
let _lock = ENV_MUTEX.lock().unwrap();
211+
clear_all_vars();
212+
set_all_gitlab_vars();
213+
std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM);
214+
215+
let mut auth = GitlabAuth::new().unwrap();
216+
let token = auth.get_token().await.unwrap();
217+
218+
let decoding_key = DecodingKey::from_ec_pem(TEST_PUBLIC_KEY_PEM.as_bytes()).unwrap();
219+
let decoded =
220+
decode::<serde_json::Value>(&token, &decoding_key, &make_validation()).unwrap();
221+
let claims = decoded.claims;
222+
223+
assert_eq!(claims["iss"], crate::auth::tpk::DEFAULT_ISSUER);
224+
assert_eq!(claims["aud"], crate::auth::tpk::DEFAULT_AUDIENCE);
225+
}
226+
227+
#[tokio::test]
228+
async fn test_tpk_fallback_token_is_cached_on_auth_struct() {
229+
let _lock = ENV_MUTEX.lock().unwrap();
230+
clear_all_vars();
231+
set_all_gitlab_vars();
232+
std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM);
233+
234+
let mut auth = GitlabAuth::new().unwrap();
235+
let token = auth.get_token().await.unwrap();
236+
237+
assert_eq!(auth.jwt.as_deref(), Some(token.as_str()));
238+
}
239+
240+
// Error Cases
241+
242+
#[tokio::test]
243+
async fn test_error_when_neither_token_source_is_available() {
244+
let _lock = ENV_MUTEX.lock().unwrap();
245+
clear_all_vars();
246+
247+
let mut auth = GitlabAuth::new().unwrap();
248+
let result = auth.get_token().await;
249+
250+
assert!(
251+
result.is_err(),
252+
"should fail when no token source is configured"
253+
);
254+
}
255+
256+
#[tokio::test]
257+
async fn test_error_when_tpk_present_but_ci_var_missing() {
258+
let _lock = ENV_MUTEX.lock().unwrap();
259+
260+
// Each predefined GitLab CI variable is required; verify that omitting
261+
// any one of them produces an error.
262+
let all_vars = [
263+
"CI_SERVER_URL",
264+
"CI_PIPELINE_ID",
265+
"CI_PROJECT_ID",
266+
"CI_PROJECT_PATH",
267+
"CI_COMMIT_REF_NAME",
268+
"CI_JOB_ID",
269+
"CI_COMMIT_SHA",
270+
];
271+
272+
for omit in all_vars {
273+
clear_all_vars();
274+
set_all_gitlab_vars();
275+
std::env::remove_var(omit);
276+
std::env::set_var("TRUSTED_PRIVATE_KEY", TEST_PRIVATE_KEY_PEM);
277+
278+
let mut auth = GitlabAuth::new().unwrap();
279+
let result = auth.get_token().await;
280+
281+
assert!(result.is_err(), "should fail when {omit} is missing");
282+
}
25283
}
26284
}

0 commit comments

Comments
 (0)