doc(auth/jwt): add README.md (condensed from doc.go)

This commit is contained in:
AJ ONeal 2026-08-14 01:47:02 -06:00
parent 5f8d7119d8
commit 6e06fa609b
No known key found for this signature in database

247
auth/jwt/README.md Normal file
View File

@ -0,0 +1,247 @@
# jwt
[![Go Reference](https://pkg.go.dev/badge/github.com/therootcompany/golib/auth/jwt.svg)](https://pkg.go.dev/github.com/therootcompany/golib/auth/jwt)
Lightweight JWT/JWS/JWK library for JOSE, OIDC, and OAuth 2.1. \
Asymmetric (Ed25519, ECDSA, RSA) and symmetric (HMAC) signing. \
Go 1.26+.
- Issuer: sign tokens, publish JWKS
- Relying Party: verify signatures, validate claims
- Key management: file-based loading, remote JWKS fetching, graceful rotation
- HMAC for legacy systems
## Issuer: Sign and Issue Tokens
Create a signer from private keys, then issue tokens with any claims type.
```go
package main
import (
"fmt"
"os"
"time"
"github.com/therootcompany/golib/auth/jwt"
"github.com/therootcompany/golib/auth/jwt/keyfile"
)
func main() {
privKey, err := keyfile.LoadPrivatePEM("signing-key.pem")
if err != nil {
panic(err)
}
signer, err := jwt.NewSigner([]*jwt.PrivateKey{privKey})
if err != nil {
panic(err)
}
// Publish the JWKS endpoint: json.Marshal(&signer.WellKnownJWKs)
claims := &jwt.TokenClaims{
Iss: "https://auth.example.com",
Sub: "user-42",
Aud: jwt.Listish{"https://api.example.com"},
Exp: time.Now().Add(time.Hour).Unix(),
IAt: time.Now().Unix(),
}
token, err := signer.SignToString(claims)
if err != nil {
panic(err)
}
fmt.Println(token)
}
```
## Relying Party: Verify and Validate
Verify the signature, then validate claims (exp, aud, iss, etc.).
```go
package main
import (
"fmt"
"os"
"time"
"github.com/therootcompany/golib/auth/jwt"
"github.com/therootcompany/golib/auth/jwt/keyfile"
)
func main() {
pubKey, err := keyfile.LoadPublicPEM("signing-key.pub")
if err != nil {
panic(err)
}
verifier, err := jwt.NewVerifier([]jwt.PublicKey{*pubKey})
if err != nil {
panic(err)
}
jws, err := verifier.VerifyJWT("eyJhbGciOiJFZERTQSIs...")
if err != nil {
switch err {
case jwt.ErrUnknownKID:
fmt.Println("Unknown signing key")
case jwt.ErrSignatureInvalid:
fmt.Println("Invalid signature")
default:
panic(err)
}
os.Exit(1)
}
var claims jwt.TokenClaims
if err := jws.UnmarshalClaims(&claims); err != nil {
panic(err)
}
validator := jwt.NewAccessTokenValidator(
[]string{"https://auth.example.com"}, // issuers
[]string{"https://api.example.com"}, // audiences
)
if err := validator.Validate(nil, &claims, time.Now()); err != nil {
fmt.Printf("Claims invalid: %v\n", err)
os.Exit(1)
}
fmt.Printf("Valid token for %s\n", claims.Sub)
}
```
## Key Management
For asymmetric keys, KID is auto-computed from the RFC 7638 thumbprint by default.
### Load Keys from Files
```go
privKey, err := keyfile.LoadPrivatePEM("key.pem")
pubKey, err := keyfile.LoadPublicPEM("key.pub")
privJWK, err := keyfile.LoadPrivateJWK("key.jwk")
pubJWK, err := keyfile.LoadPublicJWK("key.pub.jwk")
jwks, err := keyfile.LoadWellKnownJWKs("keys.jwks.json")
```
### Fetch Keys from Remote JWKS Endpoint
```go
fetcher, err := keyfetch.NewKeyFetcher("https://auth.example.com/.well-known/jwks.json")
verifier := fetcher.Verifier() // verifies with live remote keys
```
### Retired Keys
Pass retired public keys to `NewSigner` so they appear in the JWKS endpoint and can verify tokens signed before key rotation:
```go
oldPubKey, _ := keyfile.LoadPublicPEM("old-signing-key.pub")
newPrivKey, _ := keyfile.LoadPrivatePEM("new-signing-key.pem")
signer, err := jwt.NewSigner([]*jwt.PrivateKey{newPrivKey}, oldPubKey)
```
The retired key is never used for signing but appears in `signer.WellKnownJWKs` and can verify legacy tokens.
## HMAC: Legacy Signing
HMAC-signed JWTs for backwards compatibility. HS256, HS384, HS512.
```go
package main
import (
"crypto"
"os"
"time"
"github.com/therootcompany/golib/auth/jwt"
)
func main() {
secret := []byte(os.Getenv("JWT_SECRET"))
key := jwt.NewSharedSecret(secret, "optional-key-id")
signer, err := jwt.NewHMACSigner(crypto.SHA256, key)
if err != nil {
panic(err)
}
token, err := signer.SignToString(&jwt.TokenClaims{
Iss: "legacy.example.com",
Sub: "user-42",
Exp: time.Now().Add(time.Hour).Unix(),
})
if err != nil {
panic(err)
}
verifier, err := jwt.NewHMACVerifier(crypto.SHA256, key)
if err != nil {
panic(err)
}
jws, err := verifier.VerifyJWT(token)
if err != nil {
panic(err)
}
var claims jwt.TokenClaims
jws.UnmarshalClaims(&claims)
println(claims.Sub) // "user-42"
}
```
### Shared Secret from Environment
```sh
export JWT_SECRET=$(openssl rand -base64 32)
```
```go
secret := []byte(os.Getenv("JWT_SECRET"))
ss := jwt.NewSharedSecret(secret, "my-key")
signer, err := jwt.NewHMACSigner(crypto.SHA256, ss)
```
### Load Secret from File
```go
// Raw file contents as secret (KID left unset)
key, err := keyfile.LoadSharedSecretString("secret.txt")
// JWK format (same as asymmetric keys)
key, err := keyfile.LoadSharedSecretJWK("secret.jwk")
```
### Retired Secrets
For key rotation, pass retired secrets to verify tokens signed with older keys:
```go
active := jwt.NewSharedSecret(activeSecret, "active")
retired := jwt.NewSharedSecret(oldSecret, "retired")
verifier, err := jwt.NewHMACVerifier(crypto.SHA256, active, retired)
```
The verifier tries the active secret first, then retired secrets. The first successful verification wins.
### Key ID (KID)
For HMAC keys, KID is left unset — you set it explicitly.
```go
key := jwt.NewSharedSecret(secret, "")
// key.KID is empty
// To set KID from the RFC 7638 thumbprint:
key.KID, _ = key.Thumbprint()
```
It's best to leave the Key ID `kid` unset for HMAC secrets unless you have multiple secrets and you know whether using thumbprint or opaque ids is right for your use case.