Skip to content

Proposal: JWT-based authorization for notary server #812

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
kubkon opened this issue Apr 25, 2025 · 3 comments
Open

Proposal: JWT-based authorization for notary server #812

kubkon opened this issue Apr 25, 2025 · 3 comments

Comments

@kubkon
Copy link

kubkon commented Apr 25, 2025

This proposal follows up discussion about additional authorization modes during TLSN office hours and face-to-face meeting with @th4s. CCing @yuroitaki since you seem to be handling most if not all server-side things in TLSN.

Premise

Currently, authorization with TLS notary server is done via a whitelist mechanism. The whitelist has a simple format such that:

Name ApiKey CreatedAt
John Doe my_secret_key 1970-01-01

Next, authentication via the whitelist can be enabled by specifying in the server’s config.yaml

authorization:
  enabled: true
  whitelist_csv_path: "path/to/whitelist.csv"

Note that by default authorization is disabled. If enabled however, it protects all routes except /notarize which is automatically protected by a session random key which is generated by accessing /session route.

While the above mechanism is simple to use and most likely sufficient for server-based communications with the notary server, the problems appear once we move the sending end (the end that request a notarization) into the browser. Then, the browser has to somehow gain access to the secret API key, and do so in such a way as not to accidentally leak the secret to the outside world. This is finicky and error-prone.

In this issue, we would like to propose an alternative authorization mechanism that is based on JSON web token authorization. This mode of authorization would exist side-by-side as an alternative to the whitelist mechanism, and the user of the TLS notary server would be able to select the most appropriate mode of authorization for their setup.

User perspective

From the user perspective, ideally they would be able to enable JWT authorization in the config file, specify path to the decoding key (public key for validating JWTs’ signatures), and optionally specify a list of claims the JWT should carry for successful authorization. This config would co-exist with the existing whitelist config mechanism, however, only one should be possible at any one time.

Here’s the proposed format of JWT config in config.yaml:

authorization:
  enabled: true
  jwt:
    public_key_pem_path: "./fixture/auth/jwt.pub"
    claims:
      - name: sub
        values: [test]
      - name: host
        values: [api.x.com]
        value_type: string
  • public_key_pem_path - path to the decoding key
  • claims - list of required claims that each JWT has to carry

In this particular example, we require each JWT to carry sub and host claims such that

{
  "sub": "test",
  "host": "api.x.com"
}

If any of those claims is not present in the JWT, or has a different value, fails validation and ultimately ends with failure to correctly authorize with the server. Additionally, we would always validate JWT’s expiration.

The proposed format for describing the required claims is based upon a similar mechanism found in Envoy Gateway configuration.

The proposed scheme is best summarised by the following Rust target structs:

#[derive(Deserialize)]
pub struct JwtClaim {
    pub name: String,
    #[serde(default)]
    pub values: Vec<String>,
    #[serde(default)]
    pub value_type: JwtClaimValueType,
}

#[derive(Deserialize)]
#[serde(rename-all = "kebab-case")]
pub enum JwtClaimValueType {
    #[default]
    String,
}

JwtClaimValueType would initially include some basic primitives such as string and int but could of course be augmented to include more value types. Also, please note that the only required field of JwtClaim is name , while the rest of the fields assume default values such that values := Vec::new() and value_type := JwtClaimValueType::String. Finally, name is expected to be nested with . as a separator, therefore sub would mean we expect sub field at the root of the JWT claims object

{
  "exp": 12345,
  "sub": "test"
}

while my.new.sub would mean we expect three indentation levels in the JWT claims object

{
  "exp": 12345,
  "sub": "test",
  "my": {
    "new": {
      "sub": "test"
    }
  }
}

Some examples of valid claims configurations are:

  • no claims - accept anything
authorization:
  jwt:
    public_key_pem_path: "..."
  • require claim with any value
authorization:
  jwt:
    public_key_pem_path: "..."
    claims:
      - name: identity
  • require claim with some particular values and of type string
authorization:
  jwt:
    public_key_pem_path: "..."
    claims:
      - name: identity
        values: [Me, Him]
        value_type: String
  • require nested claim with some particular values and of type string
authorization:
  jwt:
    public_key_pem_path: "..."
    claims:
      - name: my.super.identity
        values: [Me, Him]
        value_type: string

Implementation perspective

From the implementation perspective, we would re-use the implementation of the existing AuthorizationMiddleware with one gotcha - JWT claims would be validated at runtime (with the exception of expiration date). This can be achieved by extracting a raw serde_json::Value as raw claims from the underlying JWT (via jsonwebtoken crate), and then we would walk the user-specified claims set in the config file to perform validation by selectively accessing elements in the extract JSON Value.

I envision the logic for validating claims to reside in crates/notary/server/src/auth.rs (or thereabouts).

Additionally, the server would always have access to user-specified claims as well as the decoding key in NotaryGlobals just like it now has access to the authorization whitelist (if present). This way, addition of JWT-based authorization mechanism would be least invasive on the notary server implementation.

An example PoC implementation is available in my fork as dff299f commit.

Demo

Example config.yaml:

# config.yaml
server:
  name: "notary-server"
  host: "0.0.0.0"
  port: 7047
  html_info: |
    <head>
      <meta charset="UTF-8">
      <meta name="author" content="tlsnotary">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <body>
      <svg width="86" height="88" viewBox="0 0 86 88" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path d="M25.5484 0.708986C25.5484 0.17436 26.1196 -0.167376 26.5923 0.0844205L33.6891 3.86446C33.9202 3.98756 34.0645 4.22766 34.0645 4.48902V9.44049H37.6129C38.0048 9.44049 38.3226 9.75747 38.3226 10.1485V21.4766L36.1936 20.0606V11.5645H34.0645V80.9919C34.0645 81.1134 34.0332 81.2328 33.9735 81.3388L30.4251 87.6388C30.1539 88.1204 29.459 88.1204 29.1878 87.6388L25.6394 81.3388C25.5797 81.2328 25.5484 81.1134 25.5484 80.9919V0.708986Z" fill="#243F5F"/>
        <path d="M21.2903 25.7246V76.7012H12.7742V34.2207H0V25.7246H21.2903Z" fill="#243F5F"/>
        <path d="M63.871 76.7012H72.3871V34.2207H76.6452V76.7012H85.1613V25.7246H63.871V76.7012Z" fill="#243F5F"/>
        <path d="M38.3226 25.7246H59.6129V34.2207H46.8387V46.9649H59.6129V76.7012H38.3226V68.2051H51.0968V55.4609H38.3226V25.7246Z" fill="#243F5F"/>
      </svg>
      <h1>Notary Server {version}!</h1>
      <ul>
        <li>public key: <pre>{public_key}</pre></li>
        <li>git commit hash: <a href="https://github.com/tlsnotary/tlsn/commit/{git_commit_hash}">{git_commit_hash}</a></li>
        <li><a href="healthcheck">health check</a></li>
        <li><a href="info">info</a></li>
      </ul>
    </body>

notarization:
  max_sent_data: 4096
  max_recv_data: 16384
  timeout: 1800

tls:
  enabled: false
  private_key_pem_path: "./fixture/tls/notary.key"
  certificate_pem_path: "./fixture/tls/notary.crt"

notary_key:
  private_key_pem_path: "./fixture/notary/notary.key"
  public_key_pem_path: "./fixture/notary/notary.pub"

logging:
  level: DEBUG

authorization:
  enabled: true
  jwt:
    public_key_pem_path: "./fixture/auth/jwt.pub"
    claims:
      - name: sub
        values: [tlsn]

concurrency: 32
  • sending a CURL with no JWT token in Authorization header:
Image
  • sending a CURL with JWT token with sub claim set as test rather than the required tlsn value:
Image
  • sending a CURL with correct JWT token:
Image

Concluding remarks

Since this feature is desired by us (the vlayer team), I would of course champion it and work with you (the maintainers) in landing it in the best shape possible.

@yuroitaki
Copy link
Member

@kubkon thanks for the detailed proposal!

One key question I have: who's the issuer of the JWT token in the first place? AFAIK JWT auth requires user to 'log in' before the issuer issues the JWT to them. They can then use it to access resources in the server, in which the server performs validation against the JWT (as described in your proposal above).

@kubkon
Copy link
Author

kubkon commented May 1, 2025

@kubkon thanks for the detailed proposal!

One key question I have: who's the issuer of the JWT token in the first place? AFAIK JWT auth requires user to 'log in' before the issuer issues the JWT to them. They can then use it to access resources in the server, in which the server performs validation against the JWT (as described in your proposal above).

The issuer of JWT token would be some auxiliary server/service. For example, in our case, we have a standalone auth server that issues JWT tokens, and we then distribute the server's public key to our other servers so that they can validate said tokens. In case of the notary server, whoever is managing the entire system (the admin) would provide the signing public key at the config level while the auth server is left unspecified by design to allow as much flexibility as possible. Additionally, the admin would then also be able to configure the required user claims in config.yaml so that the notary can verify those as well if required. If anything is still unclear let me know and I'll try to elaborate some more.

@kubkon
Copy link
Author

kubkon commented May 1, 2025

Oh, and in #817 I've provided both private and public key as fixtures where the private key bit immitates the signing authority - whatever that may be - for testing only.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants