# jwt

> Create and check signed JSON Web Tokens for logins and API access, with expiry, issuer and audience checks.

This is the Markdown version of https://osl-lang.org/packages/jwt. Every page of the OSL docs has one: add .md to its path. https://osl-lang.org/llms.txt lists them all.

```osl
import "std:jwt"
```

A JSON Web Token (JWT) is a small signed note: "this is user ada, and it is good for an hour". A server hands one out at login, the client sends it back with each request, and the server trusts what is inside once the signature checks out, with no session table to look up. `std:jwt` creates and checks tokens signed with a shared secret (HS256, HS384 or HS512).

## Make and check a token

`jwt.create(claims, secret)` signs an object into a token string. `jwt.check(token, secret)` verifies it and gives the claims back:

```osl
import "std:jwt"

string secret = "a-secret-that-is-at-least-32-bytes!"

string token = jwt.create({sub: "ada", role: "admin"}, secret).unwrap()
log token.split(".").len

auto claims = jwt.check(token, secret).unwrap()
log claims["sub"] claims["role"]
log claims["iat"] != null
```

Output:

```text
3
ada admin
true
```

A token is three dot-separated parts: header, claims, signature. `create` adds `iat`, when it was issued. 

> **Important**
>
> The secret must be at least 32 bytes for HS256 (48 for HS384, 64 for HS512). A shorter one is a `ValueError`. Make one with `crypto.randomHex(32)` and keep it in an environment variable, never in your source.

## Tokens are signed, not secret

Anyone can read what is inside a token. The signature only stops them changing it. `jwt.decode` reads a token without checking it, which is fine for showing a name in a UI, but never trust what it returns for a decision:

```osl
import "std:jwt"

string secret = "a-secret-that-is-at-least-32-bytes!"
string token = jwt.create({sub: "ada"}, secret).unwrap()

auto parts = jwt.decode(token).unwrap()
log parts.header
log parts.payload["sub"]
```

Output:

```text
{"alg":"HS256","typ":"JWT"}
ada
```

So never put passwords or private data in the claims.

## Bad tokens

`check` fails for a changed token, a wrong secret and a token that isn't one. The error kind tells you which:

```osl
import "std:jwt"

string secret = "a-secret-that-is-at-least-32-bytes!"
string other = "another-secret-that-is-32-bytes-long"
string token = jwt.create({sub: "ada"}, secret).unwrap()

log jwt.check(token, other).unwrapErr().kind
log jwt.check(token ++ "x", secret).isErr()
log jwt.check("not.a.token", secret).unwrapErr().kind

switch jwt.check(token, secret) (
  case ok(claims)
    log "welcome" claims["sub"]
  case err(e)
    log "refused:" e.kind
)
```

Output:

```text
PermissionError
true
ParseError
welcome ada
```

`PermissionError` means the signature or the audience or issuer is wrong. `ParseError` means the text isn't a token. Treat all failures the same toward the client: refuse the request.

**Quiz.** A client sends a token whose claims say `role: "admin"`, but you signed it with a different secret. What does `jwt.check` do?

- [ ] Returns the claims, since the format is valid.
- [x] Fails with a `PermissionError`.
- [ ] Returns the claims with `role` removed.

Answer: The signature is the only thing that makes the claims trustworthy.

## Expiry

Give a token a lifetime with `expiresIn`, in milliseconds. After that, `check` fails with an `ExpiredError`, and `isExpired` says so without needing the secret:

```osl
import "std:jwt"
import "std:thread"

string secret = "a-secret-that-is-at-least-32-bytes!"
string token = jwt.create({sub: "ada"}, secret, {expiresIn: 1000}).unwrap()

log jwt.isExpired(token)
thread.sleep(2100)
log jwt.isExpired(token)
log jwt.check(token, secret).unwrapErr().kind
```

Output:

```text
false
true
ExpiredError
```

Try an `expiresIn` of 60000 and the sleep no longer matters. For a login token an hour (`3600000`) is typical. Short lifetimes limit what a stolen token can do.

## Renewing

`jwt.renew(token, secret, options)` checks a token and returns a new one with the same claims and a fresh lifetime:

```osl
import "std:jwt"

string secret = "a-secret-that-is-at-least-32-bytes!"
string first = jwt.create({sub: "ada"}, secret, {expiresIn: 60000}).unwrap()

string second = jwt.renew(first, secret, {expiresIn: 3600000}).unwrap()
log jwt.check(second, secret).unwrap()["sub"]
```

Output:

```text
ada
```

## Issuer and audience

`iss` says who made the token and `aud` who it is for. Ask `check` to require them, so a token for one service isn't accepted by another:

```osl
import "std:jwt"

string secret = "a-secret-that-is-at-least-32-bytes!"
auto claims = {sub: "ada", iss: "auth.example", aud: "billing"}
string token = jwt.create(claims, secret).unwrap()

log jwt.check(token, secret, {issuer: "auth.example", audience: "billing"}).isOk()
log jwt.check(token, secret, {audience: "search"}).isErr()
log jwt.check(token, secret, {issuer: "someone.else"}).isErr()
```

Output:

```text
true
true
true
```

## Protect a route

Put it together with [serve](/packages/serve.md): a middleware reads the bearer token, checks it, and passes the claims on:

```osl
import "std:jwt"
import "std:serve"
import "std:requests"

string secret = "a-secret-that-is-at-least-32-bytes!"
auto app = serve.new()

app.use(def(*serve.Context c) (
  string token = c.bearer() ?? ""
  switch jwt.check(token, secret) (
    case ok(claims)
      c.set("user", claims["sub"])
      c.next()
    case err(e)
      c.unauthorized("invalid token")
  )
))
app.get("/me", def(*serve.Context c) (
  c.text(200, "hello " ++ c.get("user"))
))
app.start("127.0.0.1:8775").unwrap()

string good = jwt.create({sub: "ada"}, secret).unwrap()
auto headers = {Authorization: "Bearer " ++ good}
auto ok = requests.get("http://127.0.0.1:8775/me", {headers: headers}).unwrap()
log ok.status ok.body
log requests.get("http://127.0.0.1:8775/me").unwrap().status

app.stop().unwrap()
```

The request with the token prints `200 hello ada`, and the one without prints `401`.

### Exercise: Admin only

Write `adminName(token, secret)`. It returns the token's `sub` when the token is valid and its `role` claim is `"admin"`, and `"denied"` otherwise.

Starting code:

```osl
import "std:jwt"

string secret = "a-secret-that-is-at-least-32-bytes!"

def adminName(string token, string secret) string (
  return "denied"
)

string admin = jwt.create({sub: "ada", role: "admin"}, secret).unwrap()
string user = jwt.create({sub: "bob", role: "user"}, secret).unwrap()
log adminName(admin, secret)
log adminName(user, secret)
log adminName("junk", secret)
```

It should print:

```text
ada
denied
denied
```

Hint 1: `switch jwt.check(token, secret) ( case ok(claims) ... case err(e) ... )`.

Hint 2: Compare `claims["role"]` to `"admin"` before returning `claims["sub"]`.

A solution:

```osl
import "std:jwt"

string secret = "a-secret-that-is-at-least-32-bytes!"

def adminName(string token, string secret) string (
  switch jwt.check(token, secret) (
    case ok(claims)
      if claims["role"] == "admin" return claims["sub"]
      return "denied"
    case err(e)
      return "denied"
  )
)

string admin = jwt.create({sub: "ada", role: "admin"}, secret).unwrap()
string user = jwt.create({sub: "bob", role: "user"}, secret).unwrap()
log adminName(admin, secret)
log adminName(user, secret)
log adminName("junk", secret)
```

## API reference

## API reference

Every member of `std:jwt`, generated from the source of OSL.

### Functions

#### `jwt.check(string token, byte[] secret, object? options) result<object, error>`

check verifies a token signed with secret and returns its claims. It
accepts HS256, and HS384 and HS512 when secret is long enough for them
(48 and 64 bytes), never an unsigned token, and rejects a token past its
exp time or before its nbf time. Options: algorithm, the one algorithm
to accept ("HS256", "HS384" or "HS512"); leeway, the clock difference to
allow in milliseconds (default 0); audience, a string or an array of
strings, one of which the token's aud claim must hold; and issuer, a
string or an array of strings, one of which its iss claim must be. Fails
with ParseError for a malformed token, ExpiredError for an expired
token, PermissionError for a wrong signature, an unsigned token, one not
valid yet, another algorithm than the option names or one that needs a
longer secret, or the wrong audience or issuer, UnsupportedError for an
algorithm other than HS256, HS384 and HS512, and ValueError for a secret
under 32 bytes, or shorter than the algorithm option needs.

#### `jwt.create(object claims, byte[] secret, object? options) result<string, error>`

create signs claims into a token with secret. It adds iat, the time it
was issued in unix seconds, unless claims has one, and leaves claims
itself unchanged. Options: expiresIn, the token's lifetime in
milliseconds, which sets exp; and algorithm, "HS256" (the default),
"HS384" or "HS512". secret is at least as long as the algorithm's hash:
32 bytes for HS256, 48 for HS384 and 64 for HS512, such as
crypto.randomHex(32); a shorter one fails with ValueError. Throws
ValueError for a bad option or claims that can't be written as JSON.

#### `jwt.decode(string token) result<{error: string? (deprecated), header: object, payload: object}, error>`

decode reads a token's header and claims without checking its
signature, as {header, payload}; the deprecated key error is always
null. Never trust the result: use jwt.check for that. Fails with
ParseError for a malformed token.

#### `jwt.encode(any header, any payload, any secret) string`

**Deprecated.** Deprecated: use jwt.create(claims, secret, options) instead, which writes a header that matches the signature.

encode signs a raw JSON header and payload with HS256, whatever alg the
header names.

#### `jwt.getClaim(any token, any claim) any`

**Deprecated.** Deprecated: use jwt.check(token, secret)[name] for a claim you can trust, or jwt.decode(token).payload[name].

getClaim returns one claim of a token without checking its signature, or
null when it is missing or the token is malformed.

#### `jwt.isExpired(string token) boolean`

isExpired reports whether a token's exp time has passed, without
checking its signature. A token without exp never expires; a malformed
token, or one whose exp isn't a number, counts as expired.

#### `jwt.refresh(any token, any secret, any expiresIn) string`

**Deprecated.** Deprecated: use jwt.renew(token, secret, {expiresIn: ms}) instead; note that expiresIn there is in milliseconds.

refresh verifies a token like jwt.verify and signs its claims again,
expiring expiresIn seconds from now, or returns "" when it isn't valid.

#### `jwt.renew(string token, byte[] secret, object? options) result<string, error>`

renew checks a token like jwt.check and returns a new one with the same
claims, a fresh iat and, with the option expiresIn (ms), a new lifetime;
without it, the old lifetime from iat to exp carries over. Option:
algorithm, by default the old token's. Fails like jwt.check, and with
ValueError when secret is too short for the new algorithm.

#### `jwt.sign(object claims, any secret, any ...expiresIn) string`

**Deprecated.** Deprecated: use jwt.create(claims, secret, {expiresIn: ms}) instead; note that expiresIn there is in milliseconds.

sign signs claims with HS256, adding exp and iat when expiresIn, in
seconds, is above 0. Returns "" when claims can't be written as JSON.

#### `jwt.signWithExpiry(object claims, any secret, any expiresIn) string`

**Deprecated.** Deprecated: use jwt.create(claims, secret, {expiresIn: ms}) instead; note that expiresIn there is in milliseconds.

signWithExpiry signs a copy of claims with iat and exp added, expiring
expiresIn seconds from now, rounded up to the next whole second.

#### `jwt.verify(any token, any secret) object`

**Deprecated.** Deprecated: use jwt.check(token, secret) instead, which returns the claims and fails with a typed error.

verify checks an HS256 token and returns {valid: true, header, payload},
or {valid: false, error} with error one of "token_too_large",
"invalid_token_format", "invalid_signature", "invalid_payload",
"invalid_exp", "token_expired", "invalid_header" or
"unsupported_algorithm".
