mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-29 03:24:07 +00:00
WIP: feat: add auth/jwt/examples
This commit is contained in:
parent
0d99234914
commit
0800ea2491
186
auth/jwt/examples/acme-jws/acme_test.go
Normal file
186
auth/jwt/examples/acme-jws/acme_test.go
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewAccountJWS verifies ACME newAccount signing: jwk in header,
|
||||||
|
// kid absent, typ absent, payload is newAccount body.
|
||||||
|
func TestNewAccountJWS(t *testing.T) {
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := pk.PublicKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jwkBytes, err := json.Marshal(pubKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := NewAccountPayload{
|
||||||
|
TermsOfServiceAgreed: true,
|
||||||
|
Contact: []string{"mailto:cert-admin@example.com"},
|
||||||
|
}
|
||||||
|
payloadJSON, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr := &AcmeHeader{
|
||||||
|
URL: "https://acme.example.com/acme/new-account",
|
||||||
|
Nonce: "abc123-server-nonce",
|
||||||
|
JWK: json.RawMessage(jwkBytes),
|
||||||
|
// KID is empty for newAccount -- jwk is used instead.
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := signer.SignRaw(hdr, payloadJSON)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify protected header has ACME fields and no typ.
|
||||||
|
headerJSON, err := base64.RawURLEncoding.DecodeString(string(raw.Protected))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(headerJSON, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := decoded["typ"]; ok {
|
||||||
|
t.Error("ACME header must not contain typ")
|
||||||
|
}
|
||||||
|
if decoded["alg"] != "EdDSA" {
|
||||||
|
t.Errorf("alg = %v, want EdDSA", decoded["alg"])
|
||||||
|
}
|
||||||
|
if decoded["url"] != "https://acme.example.com/acme/new-account" {
|
||||||
|
t.Errorf("url = %v", decoded["url"])
|
||||||
|
}
|
||||||
|
if decoded["nonce"] != "abc123-server-nonce" {
|
||||||
|
t.Errorf("nonce = %v", decoded["nonce"])
|
||||||
|
}
|
||||||
|
if decoded["jwk"] == nil {
|
||||||
|
t.Error("newAccount header must contain jwk")
|
||||||
|
}
|
||||||
|
if _, ok := decoded["kid"]; ok {
|
||||||
|
t.Error("newAccount header must not contain kid (mutually exclusive with jwk)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature is present.
|
||||||
|
if len(raw.Signature) == 0 {
|
||||||
|
t.Fatal("signature is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify RawJWT marshals as flattened JWS JSON.
|
||||||
|
flat, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var flatMap map[string]string
|
||||||
|
if err := json.Unmarshal(flat, &flatMap); err != nil {
|
||||||
|
t.Fatalf("flattened JWS is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
for _, field := range []string{"protected", "payload", "signature"} {
|
||||||
|
if flatMap[field] == "" {
|
||||||
|
t.Errorf("flattened JWS missing %q field", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip: unmarshal flattened JWS back into a RawJWT.
|
||||||
|
var roundTrip jwt.RawJWT
|
||||||
|
if err := json.Unmarshal(flat, &roundTrip); err != nil {
|
||||||
|
t.Fatalf("unmarshal flattened JWS: %v", err)
|
||||||
|
}
|
||||||
|
if string(roundTrip.Protected) != string(raw.Protected) {
|
||||||
|
t.Error("round-trip: protected mismatch")
|
||||||
|
}
|
||||||
|
if string(roundTrip.Payload) != string(raw.Payload) {
|
||||||
|
t.Error("round-trip: payload mismatch")
|
||||||
|
}
|
||||||
|
if string(roundTrip.Signature) != string(raw.Signature) {
|
||||||
|
t.Error("round-trip: signature mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthenticatedRequestJWS verifies ACME POST-as-GET: kid in header,
|
||||||
|
// jwk absent, empty payload.
|
||||||
|
func TestAuthenticatedRequestJWS(t *testing.T) {
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACME kid is the account URL, not a key thumbprint.
|
||||||
|
// SignRaw uses the header's KID as-is (no conflict check).
|
||||||
|
accountURL := "https://acme.example.com/acme/acct/12345"
|
||||||
|
|
||||||
|
hdr := &AcmeHeader{
|
||||||
|
RFCHeader: jwt.RFCHeader{KID: accountURL},
|
||||||
|
URL: "https://acme.example.com/acme/orders",
|
||||||
|
Nonce: "def456-server-nonce",
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST-as-GET: nil payload produces empty payload segment.
|
||||||
|
raw, err := signer.SignRaw(hdr, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify protected header.
|
||||||
|
headerJSON, err := base64.RawURLEncoding.DecodeString(string(raw.Protected))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal(headerJSON, &decoded); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := decoded["typ"]; ok {
|
||||||
|
t.Error("ACME header must not contain typ")
|
||||||
|
}
|
||||||
|
if decoded["kid"] != accountURL {
|
||||||
|
t.Errorf("kid = %v, want %s", decoded["kid"], accountURL)
|
||||||
|
}
|
||||||
|
if _, ok := decoded["jwk"]; ok {
|
||||||
|
t.Error("authenticated request must not contain jwk (mutually exclusive with kid)")
|
||||||
|
}
|
||||||
|
if decoded["url"] != "https://acme.example.com/acme/orders" {
|
||||||
|
t.Errorf("url = %v", decoded["url"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify empty payload produces valid flattened JWS.
|
||||||
|
flat, err := json.Marshal(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var flatMap map[string]string
|
||||||
|
if err := json.Unmarshal(flat, &flatMap); err != nil {
|
||||||
|
t.Fatalf("flattened JWS is not valid JSON: %v", err)
|
||||||
|
}
|
||||||
|
if flatMap["payload"] != "" {
|
||||||
|
t.Errorf("POST-as-GET payload should be empty, got %q", flatMap["payload"])
|
||||||
|
}
|
||||||
|
if flatMap["signature"] == "" {
|
||||||
|
t.Error("signature is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
53
auth/jwt/examples/acme-jws/main.go
Normal file
53
auth/jwt/examples/acme-jws/main.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example acme-jws demonstrates how to use the jwt library to produce
|
||||||
|
// ACME (RFC 8555) JWS messages with custom protected header fields.
|
||||||
|
//
|
||||||
|
// ACME uses JWS with non-standard header fields:
|
||||||
|
// - url -- the ACME endpoint URL being requested
|
||||||
|
// - nonce -- anti-replay nonce obtained from the server
|
||||||
|
// - jwk -- the account's public key (for newAccount requests)
|
||||||
|
// - kid -- the account URL (for authenticated requests; mutually exclusive with jwk)
|
||||||
|
//
|
||||||
|
// ACME uses "flattened JWS JSON serialization" (RFC 7515 appendix A.7),
|
||||||
|
// not compact serialization. [jwt.Signer.SignRaw] handles the signing,
|
||||||
|
// and [jwt.RawJWT.MarshalJSON] produces the flattened JWS JSON:
|
||||||
|
//
|
||||||
|
// {"protected":"...","payload":"...","signature":"..."}
|
||||||
|
//
|
||||||
|
// See acme_test.go for working examples of both newAccount (jwk) and
|
||||||
|
// authenticated (kid) request flows.
|
||||||
|
//
|
||||||
|
// https://www.rfc-editor.org/rfc/rfc8555
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AcmeHeader is the ACME JWS protected header. It embeds [jwt.RFCHeader]
|
||||||
|
// for alg and kid (both omitempty, so typ is never serialized -- ACME
|
||||||
|
// JWS does not use it), and adds the ACME-specific url, nonce, and jwk
|
||||||
|
// fields.
|
||||||
|
type AcmeHeader struct {
|
||||||
|
jwt.RFCHeader
|
||||||
|
URL string `json:"url"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
JWK json.RawMessage `json:"jwk,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAccountPayload is the ACME newAccount request body (RFC 8555 §7.3).
|
||||||
|
type NewAccountPayload struct {
|
||||||
|
TermsOfServiceAgreed bool `json:"termsOfServiceAgreed"`
|
||||||
|
Contact []string `json:"contact"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {}
|
||||||
95
auth/jwt/examples/cached-keys/main.go
Normal file
95
auth/jwt/examples/cached-keys/main.go
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example cached-keys demonstrates persisting JWKS keys to disk so that a
|
||||||
|
// service can start verifying tokens immediately on restart without blocking
|
||||||
|
// on a network fetch.
|
||||||
|
//
|
||||||
|
// On startup, keys are loaded from a local file (if it exists) and passed
|
||||||
|
// as InitialKeys. After each Verifier() call, RefreshedAt is checked to
|
||||||
|
// detect updates, and keys are saved only when the sorted KIDs differ.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
"github.com/therootcompany/golib/auth/jwt/keyfetch"
|
||||||
|
"github.com/therootcompany/golib/auth/jwt/keyfile"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
jwksURL = "https://accounts.example.com/.well-known/jwks.json"
|
||||||
|
cacheFile = "jwks-cache.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load cached keys from disk (if any).
|
||||||
|
initialKeys, err := loadCachedKeys(cacheFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("no cached keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetcher := &keyfetch.KeyFetcher{
|
||||||
|
URL: jwksURL,
|
||||||
|
RefreshTimeout: 10 * time.Second,
|
||||||
|
InitialKeys: initialKeys,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track when we last saved so we can detect refreshes.
|
||||||
|
cachedKIDs := sortedKIDs(initialKeys)
|
||||||
|
lastSaved := time.Time{}
|
||||||
|
|
||||||
|
verifier, err := fetcher.Verifier()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to get verifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save if keys were refreshed and KIDs changed.
|
||||||
|
if fetcher.RefreshedAt().After(lastSaved) {
|
||||||
|
lastSaved = fetcher.RefreshedAt()
|
||||||
|
kids := sortedKIDs(verifier.PublicKeys())
|
||||||
|
if !slices.Equal(kids, cachedKIDs) {
|
||||||
|
if err := keyfile.SavePublicJWKs(cacheFile, verifier.PublicKeys()); err != nil {
|
||||||
|
log.Printf("save cached keys: %v", err)
|
||||||
|
} else {
|
||||||
|
cachedKIDs = kids
|
||||||
|
log.Printf("saved %d keys to %s", len(verifier.PublicKeys()), cacheFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("verifier ready with %d keys\n", len(verifier.PublicKeys()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCachedKeys reads a JWKS file and returns the keys. Returns nil
|
||||||
|
// (not an error) if the file doesn't exist.
|
||||||
|
func loadCachedKeys(path string) ([]jwt.PublicKey, error) {
|
||||||
|
jwks, err := keyfile.LoadWellKnownJWKs(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return jwks.Keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedKIDs returns the KIDs from keys in sorted order.
|
||||||
|
func sortedKIDs(keys []jwt.PublicKey) []string {
|
||||||
|
kids := make([]string, len(keys))
|
||||||
|
for i := range keys {
|
||||||
|
kids[i] = keys[i].KID
|
||||||
|
}
|
||||||
|
slices.Sort(kids)
|
||||||
|
return kids
|
||||||
|
}
|
||||||
60
auth/jwt/examples/custom-header/main.go
Normal file
60
auth/jwt/examples/custom-header/main.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example custom-header demonstrates reading a custom JOSE header field
|
||||||
|
// from a decoded JWT using [jwt.DecodeRaw] + [jwt.RawJWT.UnmarshalHeader].
|
||||||
|
//
|
||||||
|
// This is the relying-party pattern: you receive a token and need to
|
||||||
|
// inspect non-standard header fields before or after verification.
|
||||||
|
//
|
||||||
|
// For signing with custom headers, see the dpop-jws example.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MyHeader adds a nonce field to the standard JOSE header.
|
||||||
|
type MyHeader struct {
|
||||||
|
jwt.RFCHeader
|
||||||
|
Nonce string `json:"nonce,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Given a token with a custom "nonce" header field...
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
token, err := signer.SignToString(&jwt.TokenClaims{Sub: "user123"})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the raw segments, then unmarshal the header into your struct.
|
||||||
|
raw, err := jwt.DecodeRaw(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var h MyHeader
|
||||||
|
if err := raw.UnmarshalHeader(&h); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("alg: %s\n", h.Alg)
|
||||||
|
fmt.Printf("kid: %s\n", h.KID)
|
||||||
|
fmt.Printf("nonce: %q\n", h.Nonce) // empty - this token has no nonce
|
||||||
|
}
|
||||||
123
auth/jwt/examples/dpop-jws/main.go
Normal file
123
auth/jwt/examples/dpop-jws/main.go
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example dpop-jws demonstrates how to sign and decode a DPoP proof JWT
|
||||||
|
// (RFC 9449) with custom JOSE header fields.
|
||||||
|
//
|
||||||
|
// DPoP proof tokens use a custom typ ("dpop+jwt") and carry a server
|
||||||
|
// nonce in the header for replay protection. This example shows how to
|
||||||
|
// implement [jwt.SignableJWT] with a custom header struct.
|
||||||
|
//
|
||||||
|
// On the relying-party side, [jwt.DecodeRaw] + [jwt.RawJWT.UnmarshalHeader]
|
||||||
|
// gives you access to the custom fields.
|
||||||
|
//
|
||||||
|
// https://www.rfc-editor.org/rfc/rfc9449
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DPoPHeader extends the standard JOSE header with a DPoP nonce,
|
||||||
|
// as used in RFC 9449 (DPoP) proof tokens.
|
||||||
|
type DPoPHeader struct {
|
||||||
|
jwt.RFCHeader
|
||||||
|
Nonce string `json:"nonce,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DPoPJWT is a custom JWT type that carries a DPoP header.
|
||||||
|
type DPoPJWT struct {
|
||||||
|
jwt.RawJWT
|
||||||
|
Header DPoPHeader
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHeader implements [jwt.VerifiableJWT].
|
||||||
|
func (d *DPoPJWT) GetHeader() jwt.RFCHeader { return d.Header.RFCHeader }
|
||||||
|
|
||||||
|
// SetHeader implements [jwt.SignableJWT]. It merges the signer's
|
||||||
|
// alg/kid into the DPoP header, then encodes the full protected header.
|
||||||
|
func (d *DPoPJWT) SetHeader(hdr jwt.Header) error {
|
||||||
|
d.Header.RFCHeader = *hdr.GetRFCHeader()
|
||||||
|
data, err := json.Marshal(d.Header)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.Protected = []byte(base64.RawURLEncoding.EncodeToString(data))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := jwt.TokenClaims{
|
||||||
|
Iss: "https://auth.example.com",
|
||||||
|
Sub: "user123",
|
||||||
|
Aud: jwt.Listish{"https://api.example.com"},
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
IAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
dpop := &DPoPJWT{Header: DPoPHeader{
|
||||||
|
RFCHeader: jwt.RFCHeader{Typ: "dpop+jwt"},
|
||||||
|
Nonce: "server-nonce-abc123",
|
||||||
|
}}
|
||||||
|
if err := dpop.SetClaims(&claims); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignJWT merges alg/kid from the key into our DPoP header.
|
||||||
|
if err := signer.SignJWT(dpop); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
tokenStr, err := jwt.Encode(dpop)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("token:", tokenStr[:40]+"...")
|
||||||
|
|
||||||
|
// --- Relying party side: decode with custom header ---
|
||||||
|
|
||||||
|
raw, err := jwt.DecodeRaw(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var h DPoPHeader
|
||||||
|
if err := raw.UnmarshalHeader(&h); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("alg: %s\n", h.Alg)
|
||||||
|
fmt.Printf("kid: %s\n", h.KID)
|
||||||
|
fmt.Printf("typ: %s\n", h.Typ)
|
||||||
|
fmt.Printf("nonce: %s\n", h.Nonce)
|
||||||
|
|
||||||
|
// Verify with the standard path.
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
jws, err := jwt.Decode(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := verifier.Verify(jws); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("signature: valid")
|
||||||
|
}
|
||||||
142
auth/jwt/examples/http-middleware/main.go
Normal file
142
auth/jwt/examples/http-middleware/main.go
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example http-middleware demonstrates the common pattern of verifying a JWT
|
||||||
|
// in HTTP middleware, stashing the claims in the request context, and
|
||||||
|
// extracting them in a downstream handler.
|
||||||
|
//
|
||||||
|
// The context accessor pair (WithClaims / ClaimsFromContext) is defined
|
||||||
|
// here to show how simple it is - no library support needed.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppClaims embeds TokenClaims and adds application-specific fields.
|
||||||
|
type AppClaims struct {
|
||||||
|
jwt.TokenClaims
|
||||||
|
Email string `json:"email"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Context accessors ---
|
||||||
|
// Two lines of code - no library support required.
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const claimsKey contextKey = "claims"
|
||||||
|
|
||||||
|
// WithClaims returns a new context carrying the given claims.
|
||||||
|
func WithClaims(ctx context.Context, c *AppClaims) context.Context {
|
||||||
|
return context.WithValue(ctx, claimsKey, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsFromContext extracts claims from the context.
|
||||||
|
func ClaimsFromContext(ctx context.Context) (*AppClaims, bool) {
|
||||||
|
c, ok := ctx.Value(claimsKey).(*AppClaims)
|
||||||
|
return c, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// --- Setup: create a signer + verifier for demonstration ---
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
validator := jwt.NewIDTokenValidator(
|
||||||
|
[]string{"https://example.com"},
|
||||||
|
[]string{"myapp"},
|
||||||
|
nil, // azp
|
||||||
|
0, // grace period (0 = default 2s)
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Middleware: verify + validate + stash ---
|
||||||
|
authMiddleware := func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
http.Error(w, "missing bearer token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
|
||||||
|
jws, err := jwt.Decode(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := verifier.Verify(jws); err != nil {
|
||||||
|
http.Error(w, "invalid signature", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims AppClaims
|
||||||
|
if err := jws.UnmarshalClaims(&claims); err != nil {
|
||||||
|
http.Error(w, "bad claims", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hdr := jws.GetHeader()
|
||||||
|
var errs []error
|
||||||
|
errs = hdr.IsAllowedTyp(errs, []string{"JWT"})
|
||||||
|
if err := validator.Validate(errs, &claims, time.Now()); err != nil {
|
||||||
|
http.Error(w, "invalid claims", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(WithClaims(r.Context(), &claims)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handler: extract claims from context ---
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "no claims", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "hello %s (%s)\n", claims.Sub, claims.Email)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/api/me", authMiddleware(handler))
|
||||||
|
|
||||||
|
// Mint a token so we can demonstrate the round trip.
|
||||||
|
claims := &AppClaims{
|
||||||
|
TokenClaims: jwt.TokenClaims{
|
||||||
|
Iss: "https://example.com",
|
||||||
|
Sub: "user-42",
|
||||||
|
Aud: jwt.Listish{"myapp"},
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
IAt: time.Now().Unix(),
|
||||||
|
AuthTime: time.Now().Unix(),
|
||||||
|
},
|
||||||
|
Email: "user@example.com",
|
||||||
|
Roles: []string{"admin"},
|
||||||
|
}
|
||||||
|
token, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("token:", token[:40]+"...")
|
||||||
|
fmt.Println("curl -H 'Authorization: Bearer <token>' http://localhost:8080/api/me")
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", mux))
|
||||||
|
}
|
||||||
298
auth/jwt/examples/mcp-server-auth/main.go
Normal file
298
auth/jwt/examples/mcp-server-auth/main.go
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example mcp-server-auth demonstrates how an MCP (Model Context Protocol)
|
||||||
|
// server verifies OAuth 2.1 access tokens from MCP clients.
|
||||||
|
//
|
||||||
|
// MCP uses OAuth 2.1 for authorization per the spec. This example shows:
|
||||||
|
// - Bearer token extraction from Authorization headers
|
||||||
|
// - JWT signature verification and claims validation
|
||||||
|
// - Scope-based access control for MCP operations
|
||||||
|
// - A JSON-RPC handler that lists tools or executes them based on granted scopes
|
||||||
|
//
|
||||||
|
// Run the server, then use the printed curl commands to test each scope level.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MCP scope values for access control.
|
||||||
|
const (
|
||||||
|
scopeRead = "mcp:read"
|
||||||
|
scopeWrite = "mcp:write"
|
||||||
|
scopeAdmin = "mcp:admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// toolDef is the single source of truth for tool name, description, and
|
||||||
|
// required scope. Both tools/list and tools/call use this registry.
|
||||||
|
type toolDef struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Scope string `json:"-"` // minimum scope required to see/call this tool
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolRegistry is the canonical list of tools this server exposes.
|
||||||
|
var toolRegistry = []toolDef{
|
||||||
|
{"search", "Search the knowledge base", scopeRead},
|
||||||
|
{"summarize", "Summarize a document", scopeRead},
|
||||||
|
{"create_document", "Create a new document", scopeWrite},
|
||||||
|
{"manage_users", "Add or remove users from the workspace", scopeAdmin},
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Context accessors (two lines of code -- no library support required) ---
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const claimsKey contextKey = "claims"
|
||||||
|
|
||||||
|
// WithClaims returns a new context carrying the given token claims.
|
||||||
|
func WithClaims(ctx context.Context, c *jwt.TokenClaims) context.Context {
|
||||||
|
return context.WithValue(ctx, claimsKey, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsFromContext extracts claims stashed by the auth middleware.
|
||||||
|
func ClaimsFromContext(ctx context.Context) (*jwt.TokenClaims, bool) {
|
||||||
|
c, ok := ctx.Value(claimsKey).(*jwt.TokenClaims)
|
||||||
|
return c, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- JSON-RPC types (minimal subset of the MCP protocol) ---
|
||||||
|
|
||||||
|
// JSONRPCRequest is a minimal JSON-RPC 2.0 request envelope.
|
||||||
|
type JSONRPCRequest struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONRPCResponse is a minimal JSON-RPC 2.0 response envelope.
|
||||||
|
type JSONRPCResponse struct {
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
|
ID json.RawMessage `json:"id"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
Error *RPCError `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCError represents a JSON-RPC 2.0 error object.
|
||||||
|
type RPCError struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application-level JSON-RPC error codes (outside the -32768..-32000 reserved range).
|
||||||
|
const (
|
||||||
|
errCodeForbidden = -31403 // insufficient scope
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// --- Setup: self-signed key pair for demonstration ---
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
|
||||||
|
// The validator checks standard access token claims per RFC 9068.
|
||||||
|
// In production, iss and aud would match your authorization server
|
||||||
|
// and MCP server resource identifier.
|
||||||
|
validator := jwt.NewAccessTokenValidator(
|
||||||
|
[]string{"https://auth.example.com"}, // expected issuers
|
||||||
|
[]string{"https://mcp.example.com/jsonrpc"}, // expected audiences
|
||||||
|
0, // grace period (0 = default 2s)
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Auth middleware: verify signature + validate claims ---
|
||||||
|
authMiddleware := func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
http.Error(w, "missing bearer token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
|
||||||
|
jws, err := jwt.Decode(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := verifier.Verify(jws); err != nil {
|
||||||
|
http.Error(w, "invalid signature", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims jwt.TokenClaims
|
||||||
|
if err := jws.UnmarshalClaims(&claims); err != nil {
|
||||||
|
http.Error(w, "bad claims", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validator.Validate(nil, &claims, time.Now()); err != nil {
|
||||||
|
http.Error(w, "invalid claims: "+err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(WithClaims(r.Context(), &claims)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MCP JSON-RPC handler ---
|
||||||
|
mcpHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "no claims in context", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req JSONRPCRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeRPCError(w, []byte("null"), -32700, "parse error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.JSONRPC != "2.0" {
|
||||||
|
writeRPCError(w, req.ID, -32600, "invalid request: expected jsonrpc 2.0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch req.Method {
|
||||||
|
case "tools/list":
|
||||||
|
handleToolsList(w, req, claims)
|
||||||
|
case "tools/call":
|
||||||
|
handleToolsCall(w, req, claims)
|
||||||
|
default:
|
||||||
|
writeRPCError(w, req.ID, -32601, fmt.Sprintf("method not found: %s", req.Method))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/mcp", authMiddleware(mcpHandler))
|
||||||
|
|
||||||
|
// --- Mint demo tokens at each scope level ---
|
||||||
|
now := time.Now()
|
||||||
|
scopes := []struct {
|
||||||
|
label string
|
||||||
|
scope jwt.SpaceDelimited
|
||||||
|
}{
|
||||||
|
{"read-only", jwt.SpaceDelimited{scopeRead}},
|
||||||
|
{"read-write", jwt.SpaceDelimited{scopeRead, scopeWrite}},
|
||||||
|
{"admin", jwt.SpaceDelimited{scopeRead, scopeWrite, scopeAdmin}},
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("MCP server listening on :8080")
|
||||||
|
fmt.Println()
|
||||||
|
for _, s := range scopes {
|
||||||
|
token, err := signer.SignToString(&jwt.TokenClaims{
|
||||||
|
Iss: "https://auth.example.com",
|
||||||
|
Sub: "client-agent-1",
|
||||||
|
Aud: jwt.Listish{"https://mcp.example.com/jsonrpc"},
|
||||||
|
Exp: now.Add(time.Hour).Unix(),
|
||||||
|
IAt: now.Unix(),
|
||||||
|
JTI: fmt.Sprintf("tok-%s", s.label),
|
||||||
|
ClientID: "mcp-client-demo",
|
||||||
|
Scope: s.scope,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("--- %s token ---\n", s.label)
|
||||||
|
fmt.Printf("curl -s -X POST http://localhost:8080/mcp \\\n")
|
||||||
|
fmt.Printf(" -H 'Authorization: Bearer %s' \\\n", token)
|
||||||
|
fmt.Printf(" -H 'Content-Type: application/json' \\\n")
|
||||||
|
fmt.Printf(" -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}'\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Fatal(http.ListenAndServe(":8080", mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleToolsList returns the tools visible to the caller based on scopes.
|
||||||
|
func handleToolsList(w http.ResponseWriter, req JSONRPCRequest, claims *jwt.TokenClaims) {
|
||||||
|
var visible []toolDef
|
||||||
|
for _, td := range toolRegistry {
|
||||||
|
if hasScope(claims, td.Scope) {
|
||||||
|
visible = append(visible, td)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRPCResult(w, req.ID, map[string]any{
|
||||||
|
"tools": visible,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallParams holds the parameters for a tools/call request.
|
||||||
|
type CallParams struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleToolsCall executes a tool if the caller has the required scope.
|
||||||
|
func handleToolsCall(w http.ResponseWriter, req JSONRPCRequest, claims *jwt.TokenClaims) {
|
||||||
|
var params CallParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
writeRPCError(w, req.ID, -32602, "invalid params")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the tool in the single registry.
|
||||||
|
var found *toolDef
|
||||||
|
for i := range toolRegistry {
|
||||||
|
if toolRegistry[i].Name == params.Name {
|
||||||
|
found = &toolRegistry[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
writeRPCError(w, req.ID, -32602, fmt.Sprintf("unknown tool: %s", params.Name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !hasScope(claims, found.Scope) {
|
||||||
|
writeRPCError(w, req.ID, errCodeForbidden, fmt.Sprintf("insufficient scope: %s required", found.Scope))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeRPCResult(w, req.ID, map[string]any{
|
||||||
|
"content": []map[string]string{
|
||||||
|
{"type": "text", "text": fmt.Sprintf("executed %s for %s", params.Name, claims.Sub)},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasScope checks whether the token's scope claim contains the given value.
|
||||||
|
func hasScope(claims *jwt.TokenClaims, scope string) bool {
|
||||||
|
return slices.Contains(claims.Scope, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: id,
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(JSONRPCResponse{
|
||||||
|
JSONRPC: "2.0",
|
||||||
|
ID: id,
|
||||||
|
Error: &RPCError{Code: code, Message: message},
|
||||||
|
})
|
||||||
|
}
|
||||||
138
auth/jwt/examples/mfa-validator/main.go
Normal file
138
auth/jwt/examples/mfa-validator/main.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example mfa-validator demonstrates application-level AMR validation
|
||||||
|
// after [jwt.Validator.Validate]. The jwt package intentionally
|
||||||
|
// does not enforce AMR rules because there is no standard registry of
|
||||||
|
// values - each provider defines its own. This example shows how to
|
||||||
|
// check required authentication methods and minimum factor counts in
|
||||||
|
// your own code.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MFAPolicy defines what authentication methods a token must contain.
|
||||||
|
type MFAPolicy struct {
|
||||||
|
// RequiredAMRs lists method values that must all appear in the
|
||||||
|
// token's amr claim (e.g. ["pwd", "otp"]).
|
||||||
|
RequiredAMRs []string
|
||||||
|
|
||||||
|
// MinFactors is the minimum number of distinct amr values the
|
||||||
|
// token must contain. 0 means no minimum.
|
||||||
|
MinFactors int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks that claims.AMR satisfies the policy.
|
||||||
|
func (p *MFAPolicy) Validate(claims jwt.Claims) error {
|
||||||
|
amr := claims.GetTokenClaims().AMR
|
||||||
|
|
||||||
|
if len(amr) == 0 {
|
||||||
|
return fmt.Errorf("amr claim is missing or empty: %w", jwt.ErrMissingClaim)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, required := range p.RequiredAMRs {
|
||||||
|
if !slices.Contains(amr, required) {
|
||||||
|
return fmt.Errorf("amr missing %q: %w", required, jwt.ErrInvalidClaim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.MinFactors > 0 && len(amr) < p.MinFactors {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"amr has %d factor(s), need at least %d: %w",
|
||||||
|
len(amr), p.MinFactors, jwt.ErrInvalidClaim,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// --- Issuer side: create and sign a token ---
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &jwt.TokenClaims{
|
||||||
|
Iss: "https://example.com",
|
||||||
|
Sub: "user123",
|
||||||
|
Aud: jwt.Listish{"myapp"},
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
IAt: time.Now().Unix(),
|
||||||
|
AMR: []string{"pwd", "otp"},
|
||||||
|
AuthTime: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStr, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("token:", tokenStr[:40]+"...")
|
||||||
|
|
||||||
|
// --- Relying party side: verify => validate => check MFA ---
|
||||||
|
|
||||||
|
// 1. Decode and verify the signature.
|
||||||
|
jws, err := jwt.Decode(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
if err := verifier.Verify(jws); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Unmarshal and validate standard claims.
|
||||||
|
var got jwt.TokenClaims
|
||||||
|
if err := jws.UnmarshalClaims(&got); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
v := jwt.NewIDTokenValidator(
|
||||||
|
[]string{"https://example.com"},
|
||||||
|
[]string{"myapp"},
|
||||||
|
nil, // azp
|
||||||
|
0, // grace period (0 = default 2s)
|
||||||
|
)
|
||||||
|
if err := v.Validate(nil, &got, time.Now()); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check MFA policy - this is the application-level step.
|
||||||
|
mfa := &MFAPolicy{
|
||||||
|
RequiredAMRs: []string{"pwd", "otp"},
|
||||||
|
MinFactors: 2,
|
||||||
|
}
|
||||||
|
if err := mfa.Validate(&got); err != nil {
|
||||||
|
log.Fatal("MFA check failed:", err)
|
||||||
|
}
|
||||||
|
fmt.Println("MFA check: passed")
|
||||||
|
|
||||||
|
// --- Demonstrate a failure case ---
|
||||||
|
weakClaims := &jwt.TokenClaims{
|
||||||
|
Iss: "https://example.com",
|
||||||
|
Sub: "user456",
|
||||||
|
Aud: jwt.Listish{"myapp"},
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
IAt: time.Now().Unix(),
|
||||||
|
AMR: []string{"pwd"}, // only one factor
|
||||||
|
}
|
||||||
|
if err := mfa.Validate(weakClaims); err != nil {
|
||||||
|
fmt.Println("weak token rejected:", errors.Unwrap(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
112
auth/jwt/examples/oauth-access-token/main.go
Normal file
112
auth/jwt/examples/oauth-access-token/main.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example oauth-access-token demonstrates OAuth 2.1 JWT access token
|
||||||
|
// validation per RFC 9068 using NewAccessTokenValidator with RequiredScopes.
|
||||||
|
//
|
||||||
|
// It mints an access token with JTI, ClientID, and Scope fields, then
|
||||||
|
// walks through the decode / verify / unmarshal / validate pipeline.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// --- Setup: create a signer + verifier for demonstration ---
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
|
||||||
|
// --- Build access-token claims ---
|
||||||
|
// RFC 9068 requires iss, sub, aud, exp, iat, jti, and client_id.
|
||||||
|
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(),
|
||||||
|
JTI: "tok-abc-123",
|
||||||
|
ClientID: "mobile-app",
|
||||||
|
Scope: jwt.SpaceDelimited{"read:messages", "write:messages", "profile"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mint an access token (typ: at+jwt) ---
|
||||||
|
tok, err := jwt.NewAccessToken(claims)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := signer.SignJWT(tok); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
tokenStr, err := tok.Encode()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("access token:", tokenStr[:40]+"...")
|
||||||
|
|
||||||
|
// --- Decode (parse without verifying) ---
|
||||||
|
jws, err := jwt.Decode(tokenStr)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("decode:", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("header typ: %s\n", jws.GetHeader().Typ)
|
||||||
|
|
||||||
|
// --- Verify signature ---
|
||||||
|
if err := verifier.Verify(jws); err != nil {
|
||||||
|
log.Fatal("verify:", err)
|
||||||
|
}
|
||||||
|
fmt.Println("signature: OK")
|
||||||
|
|
||||||
|
// --- Unmarshal claims ---
|
||||||
|
var got jwt.TokenClaims
|
||||||
|
if err := jws.UnmarshalClaims(&got); err != nil {
|
||||||
|
log.Fatal("unmarshal:", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("sub: %s client_id: %s jti: %s\n", got.Sub, got.ClientID, got.JTI)
|
||||||
|
fmt.Printf("scope: %v\n", got.Scope)
|
||||||
|
|
||||||
|
// --- Validate typ header + claims together ---
|
||||||
|
validator := jwt.NewAccessTokenValidator(
|
||||||
|
[]string{"https://auth.example.com"}, // allowed issuers
|
||||||
|
[]string{"https://api.example.com"}, // allowed audiences
|
||||||
|
0, // grace period (0 = default 2s)
|
||||||
|
)
|
||||||
|
validator.RequiredScopes = []string{"read:messages", "profile"}
|
||||||
|
|
||||||
|
// Thread header errors into Validate so all findings appear in one error.
|
||||||
|
hdr := jws.GetHeader()
|
||||||
|
var errs []error
|
||||||
|
errs = hdr.IsAllowedTyp(errs, []string{"at+jwt"})
|
||||||
|
if err := validator.Validate(errs, &got, time.Now()); err != nil {
|
||||||
|
log.Fatal("validate:", err)
|
||||||
|
}
|
||||||
|
fmt.Println("claims: OK (typ, iss, sub, aud, exp, iat, jti, client_id, scope all valid)")
|
||||||
|
|
||||||
|
// --- Demonstrate scope rejection ---
|
||||||
|
strict := jwt.NewAccessTokenValidator(
|
||||||
|
[]string{"https://auth.example.com"},
|
||||||
|
[]string{"https://api.example.com"},
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
strict.RequiredScopes = []string{"admin:delete"} // not in the token
|
||||||
|
|
||||||
|
if err := strict.Validate(nil, &got, time.Now()); err != nil {
|
||||||
|
fmt.Println("expected rejection:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
118
auth/jwt/examples/oidc-id-token/main.go
Normal file
118
auth/jwt/examples/oidc-id-token/main.go
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
// Copyright 2026 AJ ONeal <aj@therootcompany.com> (https://therootcompany.com)
|
||||||
|
//
|
||||||
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
// Example oidc-id-token demonstrates OIDC ID Token validation using
|
||||||
|
// jwt.StandardClaims, which carries the full set of OIDC profile, email,
|
||||||
|
// and phone fields alongside the core token claims.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// --- Setup: create a signer + verifier for demonstration ---
|
||||||
|
pk, err := jwt.NewPrivateKey()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
signer, err := jwt.NewSigner([]*jwt.PrivateKey{pk})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
verifier := signer.Verifier()
|
||||||
|
|
||||||
|
// Create an ID Token validator that checks iss, sub, aud, exp, iat,
|
||||||
|
// and auth_time (per OIDC Core §3.1.3.7).
|
||||||
|
validator := jwt.NewIDTokenValidator(
|
||||||
|
[]string{"https://accounts.example.com"}, // allowed issuers
|
||||||
|
[]string{"my-app-client-id"}, // allowed audiences
|
||||||
|
nil, // azp - no authorized-party restriction
|
||||||
|
0, // grace period (0 = default 2s)
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Mint an ID Token with OIDC profile and contact fields ---
|
||||||
|
claims := &jwt.StandardClaims{
|
||||||
|
TokenClaims: jwt.TokenClaims{
|
||||||
|
Iss: "https://accounts.example.com",
|
||||||
|
Sub: "user-42",
|
||||||
|
Aud: jwt.Listish{"my-app-client-id"},
|
||||||
|
Exp: time.Now().Add(time.Hour).Unix(),
|
||||||
|
IAt: time.Now().Unix(),
|
||||||
|
AuthTime: time.Now().Unix(),
|
||||||
|
Nonce: "abc123",
|
||||||
|
},
|
||||||
|
|
||||||
|
// OIDC profile fields
|
||||||
|
Name: "Jane Doe",
|
||||||
|
GivenName: "Jane",
|
||||||
|
FamilyName: "Doe",
|
||||||
|
PreferredUsername: "janedoe",
|
||||||
|
Picture: "https://example.com/janedoe/photo.jpg",
|
||||||
|
Locale: "en-US",
|
||||||
|
|
||||||
|
// Contact fields with NullBool for *_verified
|
||||||
|
Email: "jane@example.com",
|
||||||
|
EmailVerified: jwt.NullBool{Bool: true, Valid: true},
|
||||||
|
|
||||||
|
PhoneNumber: "+1-555-867-5309",
|
||||||
|
PhoneNumberVerified: jwt.NullBool{Bool: false, Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := signer.SignToString(claims)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("signed ID token:", token[:40]+"...")
|
||||||
|
|
||||||
|
// --- Decode, verify signature, unmarshal, validate ---
|
||||||
|
jws, err := jwt.Decode(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("decode:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := verifier.Verify(jws); err != nil {
|
||||||
|
log.Fatal("verify:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got jwt.StandardClaims
|
||||||
|
if err := jws.UnmarshalClaims(&got); err != nil {
|
||||||
|
log.Fatal("unmarshal:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hdr := jws.GetHeader()
|
||||||
|
var errs []error
|
||||||
|
errs = hdr.IsAllowedTyp(errs, []string{"JWT"})
|
||||||
|
if err := validator.Validate(errs, &got, time.Now()); err != nil {
|
||||||
|
log.Fatal("validate:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Print the decoded OIDC claims ---
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("=== ID Token Claims ===")
|
||||||
|
fmt.Println("iss: ", got.Iss)
|
||||||
|
fmt.Println("sub: ", got.Sub)
|
||||||
|
fmt.Println("aud: ", got.Aud)
|
||||||
|
fmt.Println("nonce: ", got.Nonce)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("name: ", got.Name)
|
||||||
|
fmt.Println("given_name: ", got.GivenName)
|
||||||
|
fmt.Println("family_name: ", got.FamilyName)
|
||||||
|
fmt.Println("preferred: ", got.PreferredUsername)
|
||||||
|
fmt.Println("picture: ", got.Picture)
|
||||||
|
fmt.Println("locale: ", got.Locale)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("email: ", got.Email)
|
||||||
|
fmt.Printf("email_verified: value=%v valid=%v\n", got.EmailVerified.Bool, got.EmailVerified.Valid)
|
||||||
|
fmt.Println("phone: ", got.PhoneNumber)
|
||||||
|
fmt.Printf("phone_verified: value=%v valid=%v\n", got.PhoneNumberVerified.Bool, got.PhoneNumberVerified.Valid)
|
||||||
|
}
|
||||||
26
auth/jwt/examples/rfc-claims/rfc.go
Normal file
26
auth/jwt/examples/rfc-claims/rfc.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// Package rfc demonstrates a permissive RFC 7519 validator using
|
||||||
|
// [jwt.Validator] with a minimal Checks bitmask.
|
||||||
|
//
|
||||||
|
// Use this approach when tokens legitimately omit OIDC-required claims
|
||||||
|
// such as sub or aud. Prefer [jwt.NewIDTokenValidator] or
|
||||||
|
// [jwt.NewAccessTokenValidator] when you control token issuance and
|
||||||
|
// want full compliance enforced.
|
||||||
|
package rfc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRFCValidator returns a [jwt.Validator] that checks only what
|
||||||
|
// RFC 7519 requires by default: exp, iat, and nbf.
|
||||||
|
//
|
||||||
|
// Iss and aud are checked when their allowlists are non-nil.
|
||||||
|
// Additional checks can be enabled by OR-ing more Check* flags
|
||||||
|
// onto the returned Validator's Checks field.
|
||||||
|
func NewRFCValidator(iss, aud []string) *jwt.Validator {
|
||||||
|
return &jwt.Validator{
|
||||||
|
Checks: jwt.ChecksConfigured | jwt.CheckExp | jwt.CheckIAt | jwt.CheckNBf,
|
||||||
|
Iss: iss,
|
||||||
|
Aud: aud,
|
||||||
|
}
|
||||||
|
}
|
||||||
96
auth/jwt/examples/rfc-claims/rfc_test.go
Normal file
96
auth/jwt/examples/rfc-claims/rfc_test.go
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
package rfc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/therootcompany/golib/auth/jwt"
|
||||||
|
|
||||||
|
rfc "github.com/therootcompany/golib/auth/jwt/examples/rfc-claims"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNewRFCValidator shows how to use [rfc.NewRFCValidator] to build
|
||||||
|
// a permissive [jwt.Validator] that checks only exp, iat, and nbf,
|
||||||
|
// then opt in to additional checks by OR-ing more flags.
|
||||||
|
func TestNewRFCValidator(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
claims := jwt.TokenClaims{
|
||||||
|
Iss: "https://example.com",
|
||||||
|
Aud: jwt.Listish{"myapp"},
|
||||||
|
Exp: now.Add(time.Hour).Unix(),
|
||||||
|
IAt: now.Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
v := rfc.NewRFCValidator(
|
||||||
|
[]string{"https://example.com"},
|
||||||
|
[]string{"myapp"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := v.Validate(nil, &claims, now); err != nil {
|
||||||
|
t.Fatalf("NewRFCValidator rejected valid claims: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opt in to sub checking by adding the flag.
|
||||||
|
v.Checks |= jwt.CheckSub
|
||||||
|
if err := v.Validate(nil, &claims, now); !errors.Is(err, jwt.ErrMissingClaim) {
|
||||||
|
t.Fatalf("expected ErrMissingClaim for missing sub, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired token must be rejected.
|
||||||
|
expired := claims
|
||||||
|
expired.Exp = now.Add(-time.Hour).Unix()
|
||||||
|
v.Checks &^= jwt.CheckSub // remove sub check for this test
|
||||||
|
if err := v.Validate(nil, &expired, now); !errors.Is(err, jwt.ErrAfterExp) {
|
||||||
|
t.Fatalf("expected ErrAfterExp, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDirectCheckMethods shows how to call the individual check methods on
|
||||||
|
// [jwt.TokenClaims] directly, without using a [jwt.Validator] at all.
|
||||||
|
// This is useful for one-off validations or building a fully custom validator.
|
||||||
|
func TestDirectCheckMethods(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
skew := 2 * time.Second
|
||||||
|
|
||||||
|
claims := jwt.TokenClaims{
|
||||||
|
Iss: "https://example.com",
|
||||||
|
Aud: jwt.Listish{"myapp"},
|
||||||
|
Exp: now.Add(time.Hour).Unix(),
|
||||||
|
IAt: now.Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call individual check methods - each appends errors to the slice.
|
||||||
|
var errs []error
|
||||||
|
errs = claims.IsAllowedIss(errs, []string{"https://example.com"})
|
||||||
|
errs = claims.HasAllowedAud(errs, []string{"myapp"})
|
||||||
|
errs = claims.IsBeforeExp(errs, now, skew)
|
||||||
|
errs = claims.IsAfterIAt(errs, now, skew)
|
||||||
|
|
||||||
|
// No errors when all checks pass.
|
||||||
|
if err := errors.Join(errs...); err != nil {
|
||||||
|
t.Fatalf("direct checks rejected valid claims: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now validate a bad token the same way.
|
||||||
|
bad := jwt.TokenClaims{
|
||||||
|
Iss: "https://evil.com",
|
||||||
|
Exp: now.Add(-time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var badErrs []error
|
||||||
|
badErrs = bad.IsAllowedIss(badErrs, []string{"https://example.com"})
|
||||||
|
badErrs = bad.IsBeforeExp(badErrs, now, skew)
|
||||||
|
|
||||||
|
err := errors.Join(badErrs...)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected errors from bad claims")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, jwt.ErrInvalidClaim) {
|
||||||
|
t.Fatalf("expected ErrInvalidClaim for bad iss, got: %v", err)
|
||||||
|
}
|
||||||
|
if !errors.Is(err, jwt.ErrAfterExp) {
|
||||||
|
t.Fatalf("expected ErrAfterExp for expired token, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user