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 ) ]
621pub ( 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