Skip to content

RSDK-11520: Persist app+signaling connection jwts on the robot - #6384

Open
Evan Dorsky (EvanDorsky) wants to merge 18 commits into
viamrobotics:mainfrom
EvanDorsky:rsdk-11520-jwt-persist
Open

RSDK-11520: Persist app+signaling connection jwts on the robot#6384
Evan Dorsky (EvanDorsky) wants to merge 18 commits into
viamrobotics:mainfrom
EvanDorsky:rsdk-11520-jwt-persist

Conversation

@EvanDorsky

Copy link
Copy Markdown
Member

This is part of APP-8535 for resilient signaling so that robots can make connections to smart machines even if there is degradation on the app/system.

This branch adds jwt caching to NewAppConn in viam-server by wrapping calls to rpc.DialDirectGRPC in a new method, authedDialDirectGRPC.

authedDialDirectGRPC has largely the same signature and behavior as rpc.DialDirectGRPC - it dials the requested server (app or signaling) and returns an rpc.ClientConn.

However, authedDialDirectGRPC also explicitly authenticates the connection - instead of relying on lazy auth - and then caches the resulting jwt on disk. It proceeds as usual on the happy path, but if - and only if - authentication fails, authedDialDirectGRPC will attempt to use the corresponding cached token to authenticate its connection.

Tokens are cached in $VIAM_HOME/grpc and are named with a (filepath-safe) base64 encoding of the machine's part ID + the host that minted the token.

This branch does not include any cache invalidation i.e. client-side token revocation. I think the server (app/signaling) is the right place for revocation to live, and this change to rdk should continue to work if we change our revocation policy, since we always start by requesting a new token.


While approaching resilient signaling, I went for this change first for a few reasons:

  • it's an rdk-only change
  • it doesn't degrade the security story (see below)
  • it takes advantage of an existing authentication mechanism
  • it allows machines to connect to the signaling server even when the signaling server can't mint a token but is otherwise working
  • it also makes app connections more resilient (though we could change this)

Why this doesn't degrade the security story

note: here, "rdk" refers to viam-server and "server" refers to app or signaling. "token" refers to the jwt issued by the server.

rdk provides two pieces of information to the server it's dialing when it authenticates its connection with that server:

  • robot part id
  • robot part secret
  • (there's also a shared secret, but it's generated from these two as far as I understand, so it doesn't count)

The server uses this information to mint a token which it sends back to rdk. rdk caches this token in process memory and uses it to authenticate its connection with the host. Today, the token is lost when viam-server restarts.

Caching the token on disk does not degrade the security story at all, because the robot part id and secret - all we need to get a brand new token from the server - are already stored unencrypted on disk inside the cloud config .json file that robots need to connect to app (this is why they need that file to connect to app).

Also, rdk is open source, so in theory anybody could just fork rdk, build their own binary which cached the token like we do here, and point their robot to that new binary.

Basically, any threat model that involves compromising this cached token (i.e. physical/root access to the machine) also involves compromising the secrets required to request the token.

Caching tokens on disk actually pairs well with moving the robot part ID and secret from the filesystem into a user's env, which - combined with more aggressive token revocation - could open a path to marginally improving the security story.


I'm still new to the auth system so I might have gotten things wrong, will definitely appreciate the review.

@viambot viambot added the safe to test This pull request is marked safe to test from a trusted zone label Aug 25, 2026
@EvanDorsky Evan Dorsky (EvanDorsky) changed the title RSDK-11520: Persist signaling JWTs on the dialer RSDK-11520: Persist app+signaling connection jwts on the robot Aug 25, 2026
@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Aug 25, 2026
Comment thread grpc/app_conn.go Outdated

authenticator, ok := conn.(rpc.ClientConnAuthenticator)
if !ok {
// we cannot auth this connection, but it might still be valid, so just return it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't expect any code paths to hit this? If so, let's log as a warning.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we know the types of everything we're returning (even if it's a bit hidden in goutils), so we should know if something is wrong/unexpected. the comment/log should be more opinionated on that front

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, good idea. I will need to test whether we hit this path when the user has explicitly disabled authentication in their config. If we do, warning level is probably still fine since that's a rare use case.

Comment thread grpc/app_conn.go Outdated
"error", authErr, "code", code.String())
return conn, nil
}
// auth failed but our token was not explicitly rejected, so check if we have a cached token, then try dialing with it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you describe the scenario where this helps?

Comment thread grpc/app_conn.go Outdated
// dial completely failed, pass the results through for the retry loop
return conn, err
}

@dgottlieb Dan Gottlieb (dgottlieb) Aug 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest of this function omits newlines. 66 lines of concise logic, minus the 1 empty line here. This is a real missed opportunity. /s

Comment thread grpc/app_conn.go Outdated
return newConn, nil
}
// just in case we got an empty token, we don't want to overwrite a potentially good cache
// this check is most likely unnecessary, but it's cheap and harmless

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I disagree this is harmless. I think it's good to call out the assumption that the token is not nil. I think we should log if the string is nil.

Comment thread grpc/app_conn.go Outdated
// if our auth request gets denied with certain failure codes, it means serverside auth is working fine
// and our credentials were just bad. in that case, we want to fail early instead of trying the cached token,
// which could mask a real problem with auth
if code := status.Code(authErr); code == codes.Unauthenticated || code == codes.PermissionDenied {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need to be clear about what error code "KMS is down" manifests as.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - I'll be doing testing today to figure this out.

Comment thread grpc/app_conn.go
// authedDialDirectGRPC calls DialDirectGRPC while also explicitly authenticating the connection
// and caching the resulting token on disk. If the normal auth path fails, we attempt to auth
// with the cached token
func authedDialDirectGRPC(ctx context.Context,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting to the bottom of this function, I think the answer to most of the "what case hits this" is "we don't know".

Please let me know if that's not the case for some of them. Assuming I'm right though, I'd prefer to simplify the flow through exactly what we expect. We can certainly log more such that if this isn't working right for a customer, we can ascertain whether those other code paths would have been helpful. But I think we can save some cognitive load by reducing this down to:

  • Dial to app presenting our robot secret to exchange for a JWT
    • If that succeeds, write down the new JWT. Exit
    • If that fails, dial with a cached JWT.
      • If the cached JWT works, early exit -- there's nothing new to write to disk.
      • Distinguish between "no cached jwt file" and "cannot read cached jwt file" errors for logging.

I think the code for this function should also be opinionated. Particularly about variable names. So calling out robot secret vs signed JWT. We can relax some on the name there, but I think the vague "token" is too opaque. We want to communicate that this is a signed token. Giving the user intuition why the follow-up might succeed where the original had a problem.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading through the code again today, I'm agreeing with you that the variable names should be more opinionated, I think that will make the logic and intent easier to follow.

And I do have ideas for what case would hit most of the error branches in here, but I don't think the comments are doing a good enough job explaining them. Also the happy path is kind of buried by all the error handling. I think better comments + variable names + a bit of refactoring will improve this a lot, and I might be able to get rid of a few cases too. I'll be back with this soon. Also good call on "no cached jwt" vs "can't read jwt".

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Distinguish between "no cached jwt file" and "cannot read cached jwt file" errors for logging.

I think logging the error message from jwtCacheRead (new name) will differentiate between these cases, but let me know if you want stronger differentiation than that - this feels like enough to me.

Comment thread grpc/app_conn.go Outdated

func tokenInfo(partID, host string) (string, string) {
cacheDir := filepath.Join(rutils.ViamDotDir, "grpc")
tokenFilename := base64.RawURLEncoding.EncodeToString([]byte(partID + "_" + host))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dumb question, what is host? I'm guessing it's app.viam.com or app.viam.dev? Is it just for guaranteed uniqueness as opposed to "statistically unique"?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, those are the possible hosts. I was a little confused here at first because I didn't understand that the signaling server and app share the same url - so I was adding host to the filename to make sure that tokens from different hosts wouldn't overwrite each other.

I still think accounting for host in the filename is still reasonable, since in testing (and maybe at some point in the future?) the app and signaling hosts will sometimes be different.

@cheukt Cheuk (cheukt) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generally agree with dan's comments, I think the code/comments should be more precise about what can/cannot happen and what is expected/unexpected so that future readers are not taking on the cognitive burden of trying to figure out what might be happening

Comment thread grpc/app_conn.go Outdated

authenticator, ok := conn.(rpc.ClientConnAuthenticator)
if !ok {
// we cannot auth this connection, but it might still be valid, so just return it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we know the types of everything we're returning (even if it's a bit hidden in goutils), so we should know if something is wrong/unexpected. the comment/log should be more opinionated on that front

Comment thread grpc/app_conn.go Outdated
// if our auth request gets denied with certain failure codes, it means serverside auth is working fine
// and our credentials were just bad. in that case, we want to fail early instead of trying the cached token,
// which could mask a real problem with auth
if code := status.Code(authErr); code == codes.Unauthenticated || code == codes.PermissionDenied {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Comment thread grpc/app_conn.go
if err != nil {
return err
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably want to make these atomic - we have an atomic store in goutils

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. Claude recommended against pulling in the goutils implementation - since goutils.artifact pulls in all the gcs dependencies which are pretty heavy - so I just replicated the atomic write logic here, which wasn't that bad. Hope that sounds reasonable.

@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Sep 1, 2026
@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Sep 1, 2026
@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Sep 1, 2026
@viambot viambot added safe to test This pull request is marked safe to test from a trusted zone and removed safe to test This pull request is marked safe to test from a trusted zone labels Sep 2, 2026
@EvanDorsky

Copy link
Copy Markdown
Member Author

Re-requested reviews - thanks for all the input. I made the variable names, error messages, and comments a lot more clear and specific to what this change is actually doing. Also factored the retry path into its own function so that the happy path is much more clear.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

safe to test This pull request is marked safe to test from a trusted zone

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants