@@ -9,25 +9,29 @@ use openshell_core::jwt::{AuthenticatedSandboxSession, CredentialEpoch, SessionJ
99use openshell_core:: proto:: { Sandbox , SandboxPhase } ;
1010use openshell_core:: sandbox_generation:: SandboxGenerationId ;
1111use tonic:: Status ;
12+ use uuid:: Uuid ;
1213
1314use crate :: persistence:: Store ;
1415
1516pub const RUNTIME_GENERATION_ANNOTATION : & str = "internal.openshell.ai/runtime-generation" ;
1617pub const AUTH_EPOCH_ANNOTATION : & str = "internal.openshell.ai/auth-epoch" ;
18+ pub const GATEWAY_TOKEN_ID_ANNOTATION : & str = "internal.openshell.ai/gateway-token-id" ;
1719
1820/// The complete durable authorization identity for one sandbox runtime.
1921#[ derive( Clone , Debug , PartialEq , Eq ) ]
2022pub struct PersistedSandboxIdentity {
2123 pub runtime_generation : SandboxGenerationId ,
2224 pub auth_epoch : CredentialEpoch ,
25+ pub gateway_token_id : Uuid ,
2326}
2427
2528impl PersistedSandboxIdentity {
2629 pub fn new ( ) -> Result < Self , SessionJwtError > {
2730 Ok ( Self {
28- runtime_generation : SandboxGenerationId :: parse ( uuid :: Uuid :: new_v4 ( ) . to_string ( ) )
31+ runtime_generation : SandboxGenerationId :: parse ( Uuid :: new_v4 ( ) . to_string ( ) )
2932 . map_err ( |_| SessionJwtError :: InvalidRuntimeIdentity ) ?,
3033 auth_epoch : CredentialEpoch :: new ( 1 ) ?,
34+ gateway_token_id : Uuid :: new_v4 ( ) ,
3135 } )
3236 }
3337
@@ -45,9 +49,14 @@ impl PersistedSandboxIdentity {
4549 . parse :: < u64 > ( )
4650 . map_err ( |_| SessionJwtError :: InvalidCredentialEpoch )
4751 . and_then ( CredentialEpoch :: new) ?;
52+ let gateway_token_id = annotations
53+ . get ( GATEWAY_TOKEN_ID_ANNOTATION )
54+ . ok_or ( SessionJwtError :: MissingRuntimeIdentity )
55+ . and_then ( |value| Uuid :: parse_str ( value) . map_err ( |_| SessionJwtError :: InvalidJti ) ) ?;
4856 Ok ( Self {
4957 runtime_generation,
5058 auth_epoch,
59+ gateway_token_id,
5160 } )
5261 }
5362
@@ -60,18 +69,32 @@ impl PersistedSandboxIdentity {
6069 AUTH_EPOCH_ANNOTATION . to_string ( ) ,
6170 self . auth_epoch . get ( ) . to_string ( ) ,
6271 ) ;
72+ annotations. insert (
73+ GATEWAY_TOKEN_ID_ANNOTATION . to_string ( ) ,
74+ self . gateway_token_id . to_string ( ) ,
75+ ) ;
76+ }
77+
78+ #[ must_use]
79+ pub fn next_gateway_token ( & self ) -> Self {
80+ Self {
81+ runtime_generation : self . runtime_generation . clone ( ) ,
82+ auth_epoch : self . auth_epoch ,
83+ gateway_token_id : Uuid :: new_v4 ( ) ,
84+ }
6385 }
6486}
6587
6688/// Load the authoritative runtime identity and compare it with a signed JWT.
6789///
68- /// Every gateway replica performs this check against shared persistence. No
69- /// raw token, token ID, or refresh lineage needs to be replicated.
90+ /// Every gateway replica checks the persisted gateway-token ID in addition to
91+ /// the runtime generation and authorization epoch. Refresh replaces that ID
92+ /// with CAS, so an older bearer cannot rejoin the active credential lineage.
7093#[ allow( clippy:: result_large_err) ]
71- pub async fn authorize_persisted (
94+ async fn load_authorized (
7295 store : & Store ,
7396 principal : & AuthenticatedSandboxSession ,
74- ) -> Result < PersistedSandboxIdentity , Status > {
97+ ) -> Result < ( Sandbox , PersistedSandboxIdentity ) , Status > {
7598 let sandbox = store
7699 . get_message :: < Sandbox > ( principal. sandbox_id . as_str ( ) )
77100 . await
@@ -98,14 +121,69 @@ pub async fn authorize_persisted(
98121 . ok_or_else ( || Status :: unauthenticated ( "sandbox identity metadata is missing" ) ) ?;
99122 let identity = PersistedSandboxIdentity :: read ( & metadata. annotations )
100123 . map_err ( |_| Status :: unauthenticated ( "sandbox runtime identity is invalid" ) ) ?;
101- if principal. runtime_generation != identity. runtime_generation
102- || principal. auth_epoch != identity. auth_epoch
103- {
124+ let presented = (
125+ & principal. runtime_generation ,
126+ principal. auth_epoch ,
127+ principal. token_id ,
128+ ) ;
129+ let expected = (
130+ & identity. runtime_generation ,
131+ identity. auth_epoch ,
132+ identity. gateway_token_id ,
133+ ) ;
134+ if presented != expected {
104135 return Err ( Status :: unauthenticated (
105136 "gateway token does not match the active sandbox identity" ,
106137 ) ) ;
107138 }
108- Ok ( identity)
139+ Ok ( ( sandbox, identity) )
140+ }
141+
142+ #[ allow( clippy:: result_large_err) ]
143+ pub async fn authorize_persisted (
144+ store : & Store ,
145+ principal : & AuthenticatedSandboxSession ,
146+ ) -> Result < PersistedSandboxIdentity , Status > {
147+ load_authorized ( store, principal)
148+ . await
149+ . map ( |( _, identity) | identity)
150+ }
151+
152+ /// Atomically consume the presented gateway bearer and install its successor.
153+ ///
154+ /// A concurrent refresh or unrelated sandbox mutation causes the CAS to fail;
155+ /// callers retry from freshly authenticated state. The old token is never
156+ /// accepted after a successful update on any gateway replica.
157+ #[ allow( clippy:: result_large_err) ]
158+ pub async fn rotate_gateway_token (
159+ store : & Store ,
160+ principal : & AuthenticatedSandboxSession ,
161+ next : & PersistedSandboxIdentity ,
162+ ) -> Result < PersistedSandboxIdentity , Status > {
163+ let ( sandbox, current) = load_authorized ( store, principal) . await ?;
164+ if next. runtime_generation != current. runtime_generation
165+ || next. auth_epoch != current. auth_epoch
166+ || next. gateway_token_id == current. gateway_token_id
167+ {
168+ return Err ( Status :: internal ( "gateway token successor is invalid" ) ) ;
169+ }
170+ let metadata = sandbox
171+ . metadata
172+ . as_ref ( )
173+ . ok_or_else ( || Status :: unauthenticated ( "sandbox identity metadata is missing" ) ) ?;
174+ store
175+ . update_message_cas :: < Sandbox , _ > (
176+ principal. sandbox_id . as_str ( ) ,
177+ metadata. resource_version ,
178+ |sandbox| {
179+ if let Some ( metadata) = sandbox. metadata . as_mut ( ) {
180+ next. write ( & mut metadata. annotations ) ;
181+ }
182+ } ,
183+ )
184+ . await
185+ . map_err ( |error| Status :: aborted ( format ! ( "rotate gateway token: {error}" ) ) ) ?;
186+ Ok ( next. clone ( ) )
109187}
110188
111189#[ cfg( test) ]
@@ -121,6 +199,7 @@ mod tests {
121199 runtime_generation : SandboxGenerationId :: parse ( "generation-a" )
122200 . expect ( "runtime generation" ) ,
123201 auth_epoch : CredentialEpoch :: new ( 1 ) . expect ( "auth epoch" ) ,
202+ gateway_token_id : Uuid :: from_u128 ( 1 ) ,
124203 } ;
125204 let mut metadata = ObjectMeta {
126205 id : "sandbox-a" . to_string ( ) ,
@@ -140,13 +219,13 @@ mod tests {
140219 store. put_message ( & sandbox) . await . expect ( "persist sandbox" ) ;
141220 }
142221
143- fn principal ( auth_epoch : u64 ) -> AuthenticatedSandboxSession {
222+ fn principal ( auth_epoch : u64 , token_id : Uuid ) -> AuthenticatedSandboxSession {
144223 AuthenticatedSandboxSession {
145224 sandbox_id : SandboxId :: parse ( "sandbox-a" ) . expect ( "sandbox ID" ) ,
146225 runtime_generation : SandboxGenerationId :: parse ( "generation-a" )
147226 . expect ( "runtime generation" ) ,
148227 auth_epoch : CredentialEpoch :: new ( auth_epoch) . expect ( "auth epoch" ) ,
149- token_id : Uuid :: new_v4 ( ) ,
228+ token_id,
150229 issued_at : 1 ,
151230 expires_at : 2 ,
152231 }
@@ -157,30 +236,36 @@ mod tests {
157236 let store = Store :: connect ( "sqlite::memory:" ) . await . expect ( "store" ) ;
158237 persist_sandbox ( & store, SandboxPhase :: Ready ) . await ;
159238
160- authorize_persisted ( & store, & principal ( 1 ) )
239+ authorize_persisted ( & store, & principal ( 1 , Uuid :: from_u128 ( 1 ) ) )
161240 . await
162241 . expect ( "first replica authorizes from persistence" ) ;
163- authorize_persisted ( & store, & principal ( 1 ) )
242+ authorize_persisted ( & store, & principal ( 1 , Uuid :: from_u128 ( 1 ) ) )
164243 . await
165244 . expect ( "second replica authorizes without local state" ) ;
166245
246+ let error = authorize_persisted ( & store, & principal ( 1 , Uuid :: from_u128 ( 2 ) ) )
247+ . await
248+ . expect_err ( "a different token lineage must be rejected" ) ;
249+ assert_eq ! ( error. code( ) , tonic:: Code :: Unauthenticated ) ;
250+
167251 store
168252 . update_message_cas :: < Sandbox , _ > ( "sandbox-a" , 0 , |sandbox| {
169253 let next = PersistedSandboxIdentity {
170254 runtime_generation : SandboxGenerationId :: parse ( "generation-a" )
171255 . expect ( "runtime generation" ) ,
172256 auth_epoch : CredentialEpoch :: new ( 2 ) . expect ( "auth epoch" ) ,
257+ gateway_token_id : Uuid :: from_u128 ( 2 ) ,
173258 } ;
174259 next. write ( & mut sandbox. metadata . as_mut ( ) . expect ( "metadata" ) . annotations ) ;
175260 } )
176261 . await
177262 . expect ( "advance auth epoch" ) ;
178263
179- let error = authorize_persisted ( & store, & principal ( 1 ) )
264+ let error = authorize_persisted ( & store, & principal ( 1 , Uuid :: from_u128 ( 1 ) ) )
180265 . await
181266 . expect_err ( "old epoch must be revoked on every replica" ) ;
182267 assert_eq ! ( error. code( ) , tonic:: Code :: Unauthenticated ) ;
183- authorize_persisted ( & store, & principal ( 2 ) )
268+ authorize_persisted ( & store, & principal ( 2 , Uuid :: from_u128 ( 2 ) ) )
184269 . await
185270 . expect ( "new epoch is active" ) ;
186271
@@ -190,7 +275,7 @@ mod tests {
190275 } )
191276 . await
192277 . expect ( "stop sandbox" ) ;
193- let error = authorize_persisted ( & store, & principal ( 2 ) )
278+ let error = authorize_persisted ( & store, & principal ( 2 , Uuid :: from_u128 ( 2 ) ) )
194279 . await
195280 . expect_err ( "stopped runtime must reject its token" ) ;
196281 assert_eq ! ( error. code( ) , tonic:: Code :: FailedPrecondition ) ;
@@ -202,9 +287,34 @@ mod tests {
202287 let store = Store :: connect ( "sqlite::memory:" ) . await . expect ( "store" ) ;
203288 persist_sandbox ( & store, phase) . await ;
204289
205- authorize_persisted ( & store, & principal ( 1 ) )
290+ authorize_persisted ( & store, & principal ( 1 , Uuid :: from_u128 ( 1 ) ) )
206291 . await
207292 . expect ( "terminal runtime can finish delivering exit state" ) ;
208293 }
209294 }
295+
296+ #[ tokio:: test]
297+ async fn rotating_gateway_token_revokes_its_predecessor ( ) {
298+ let store = Store :: connect ( "sqlite::memory:" ) . await . expect ( "store" ) ;
299+ persist_sandbox ( & store, SandboxPhase :: Ready ) . await ;
300+ let first = principal ( 1 , Uuid :: from_u128 ( 1 ) ) ;
301+
302+ let expected = PersistedSandboxIdentity {
303+ runtime_generation : first. runtime_generation . clone ( ) ,
304+ auth_epoch : first. auth_epoch ,
305+ gateway_token_id : Uuid :: from_u128 ( 2 ) ,
306+ } ;
307+ let next = rotate_gateway_token ( & store, & first, & expected)
308+ . await
309+ . expect ( "rotate current gateway token" ) ;
310+ assert_ne ! ( next. gateway_token_id, first. token_id) ;
311+
312+ let error = authorize_persisted ( & store, & first)
313+ . await
314+ . expect_err ( "consumed gateway token must be rejected" ) ;
315+ assert_eq ! ( error. code( ) , tonic:: Code :: Unauthenticated ) ;
316+ authorize_persisted ( & store, & principal ( 1 , next. gateway_token_id ) )
317+ . await
318+ . expect ( "successor gateway token is current" ) ;
319+ }
210320}
0 commit comments