Share current-token verification and scoped cleanup through one producer client
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
This commit is contained in:
parent
67a0869c1e
commit
08c083bae9
19 changed files with 2228 additions and 3 deletions
379
adapters/go/tokenstatus/pkg/tokenstatus/client.go
Normal file
379
adapters/go/tokenstatus/pkg/tokenstatus/client.go
Normal file
|
|
@ -0,0 +1,379 @@
|
|||
package tokenstatus
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"keycape/internal/jose"
|
||||
)
|
||||
|
||||
var ErrOnlineToken = errors.New("current authenticated token refused")
|
||||
|
||||
// OnlineAdmission is independently admitted owner configuration. The public
|
||||
// JWKS/CA and private client credential files are reread for every request.
|
||||
// It selects the owning confidential client, not a resource-server delegation.
|
||||
type OnlineAdmission struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Audience string `json:"audience"`
|
||||
ClientID string `json:"client_id"`
|
||||
CAFile string `json:"ca_file"`
|
||||
JWKSFile string `json:"jwks_file"`
|
||||
SecretFile string `json:"secret_file"`
|
||||
MaxTokenAge string `json:"max_token_age"`
|
||||
}
|
||||
|
||||
type ExpectedIdentity struct {
|
||||
Subject string
|
||||
Tenant string
|
||||
PrincipalType string
|
||||
}
|
||||
|
||||
// CurrentToken contains signed identity facts joined to a current issuer
|
||||
// observation. It is never a permission, approval or portable execution receipt.
|
||||
type CurrentToken struct {
|
||||
Issuer string `json:"issuer"`
|
||||
Audience string `json:"audience"`
|
||||
ClientID string `json:"client_id"`
|
||||
Subject string `json:"subject"`
|
||||
Tenant string `json:"tenant"`
|
||||
PrincipalType string `json:"principal_type"`
|
||||
TokenID string `json:"jti"`
|
||||
TokenDigest string `json:"token_sha256"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
claims map[string]interface{}
|
||||
}
|
||||
|
||||
// Claims returns a separate copy of the verified signed facts. Authentication
|
||||
// assurance/age, tenant provenance, current groups and resource/effect policy
|
||||
// remain the consumer's existing profile obligations, not a new client policy.
|
||||
func (t CurrentToken) Claims() map[string]interface{} {
|
||||
if t.claims == nil {
|
||||
return nil
|
||||
}
|
||||
raw, _ := json.Marshal(t.claims)
|
||||
var out map[string]interface{}
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
// InactiveForClient proves only the issuer's current negative response for the
|
||||
// named client/token digest. An unknown/other-client token gives the same result;
|
||||
// original grant ownership is separately required before asserting cleanup.
|
||||
type InactiveForClient struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"client_id"`
|
||||
TokenDigest string `json:"token_sha256"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
type OnlineClient struct {
|
||||
admission OnlineAdmission
|
||||
age time.Duration
|
||||
now func() time.Time
|
||||
// Nil uses the normal resolver/dialer. Tests route a certified DNS name to
|
||||
// isolated loopback; the TLS name/CA and JWT trust checks remain unchanged.
|
||||
dialContext func(context.Context, string, string) (net.Conn, error)
|
||||
}
|
||||
|
||||
func NewOnlineClient(a OnlineAdmission) (*OnlineClient, error) {
|
||||
u, err := url.Parse(a.Issuer)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || strings.ContainsAny(a.Issuer, "?#") || u.RawPath != "" || (u.Path != "" && path.Clean(u.Path) != u.Path) || strings.HasSuffix(a.Issuer, "/") {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
if a.Audience == "" || a.ClientID == "" || !filepath.IsAbs(a.CAFile) || !filepath.IsAbs(a.JWKSFile) || !filepath.IsAbs(a.SecretFile) {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
age, err := time.ParseDuration(a.MaxTokenAge)
|
||||
if err != nil || age <= 0 || age > time.Hour {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
return &OnlineClient{admission: a, age: age, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func readOnlineFile(path string, private bool) ([]byte, error) {
|
||||
limit := int64(1 << 20)
|
||||
if private {
|
||||
limit = 16384
|
||||
}
|
||||
return readOnlineBoundedFile(path, private, limit)
|
||||
}
|
||||
|
||||
func readOnlineBoundedFile(path string, private bool, limit int64) ([]byte, error) {
|
||||
if limit <= 0 || limit > 1<<20 {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0022 != 0 || (private && info.Mode().Perm()&0007 != 0) {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
|
||||
if err != nil || len(raw) > int(limit) || len(raw) == 0 {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func tokenDigest(raw string) string {
|
||||
digest := sha256.Sum256([]byte(raw))
|
||||
return "sha256:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func exactUnix(value interface{}) (time.Time, bool) {
|
||||
n, ok := value.(float64)
|
||||
if !ok || n <= 0 || n > 9007199254740991 || math.Trunc(n) != n {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(int64(n), 0).UTC(), true
|
||||
}
|
||||
|
||||
func (c *OnlineClient) verify(raw string, expected ExpectedIdentity, now time.Time) (CurrentToken, error) {
|
||||
var out CurrentToken
|
||||
if c == nil || len(raw) == 0 || len(raw) > 32768 || expected.Subject == "" || expected.Tenant == "" || (expected.PrincipalType != "human" && expected.PrincipalType != "service") {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
keysRaw, err := readOnlineFile(c.admission.JWKSFile, false)
|
||||
if err != nil {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
keys, err := jose.ParseJWKS(keysRaw)
|
||||
if err != nil {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
claims, err := jose.VerifyTyped(raw, keys, "JWT")
|
||||
if err != nil {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
if claims["iss"] != c.admission.Issuer || claims["aud"] != c.admission.Audience || claims["sub"] != expected.Subject || claims["tenant"] != expected.Tenant || claims["principal_type"] != expected.PrincipalType {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
if azp, present := claims["azp"]; present && azp != c.admission.ClientID {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
_, scopeOK := claims["scope"].(string)
|
||||
jti, jtiOK := claims["jti"].(string)
|
||||
decodedID, idErr := base64.RawURLEncoding.Strict().DecodeString(jti)
|
||||
issued, issuedOK := exactUnix(claims["iat"])
|
||||
expires, expiresOK := exactUnix(claims["exp"])
|
||||
if !scopeOK || !jtiOK || idErr != nil || len(decodedID) != 32 || !issuedOK || !expiresOK || issued.After(now) || !expires.After(issued) || !now.Before(expires) {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
if nbf, present := claims["nbf"]; present {
|
||||
start, valid := exactUnix(nbf)
|
||||
if !valid || start.After(now) {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
}
|
||||
end := issued.Add(c.age)
|
||||
if expires.Before(end) {
|
||||
end = expires
|
||||
}
|
||||
if !now.Before(end) {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
return CurrentToken{Issuer: c.admission.Issuer, Audience: c.admission.Audience, ClientID: c.admission.ClientID,
|
||||
Subject: expected.Subject, Tenant: expected.Tenant, PrincipalType: expected.PrincipalType,
|
||||
TokenID: jti, TokenDigest: tokenDigest(raw), IssuedAt: issued, NotAfter: end, CheckedAt: now, claims: claims}, nil
|
||||
}
|
||||
|
||||
type statusWire struct {
|
||||
Active *bool `json:"active"`
|
||||
ClientID string `json:"client_id"`
|
||||
TokenType string `json:"token_type"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
Scope string `json:"scope"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
TokenID string `json:"jti"`
|
||||
}
|
||||
|
||||
func decodeOnlineObject(raw []byte, out interface{}) (map[string]bool, error) {
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
first, err := d.Token()
|
||||
if err != nil || first != json.Delim('{') {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for d.More() {
|
||||
key, err := d.Token()
|
||||
name, ok := key.(string)
|
||||
if err != nil || !ok || seen[name] {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
seen[name] = true
|
||||
var value json.RawMessage
|
||||
if d.Decode(&value) != nil || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
}
|
||||
if last, err := d.Token(); err != nil || last != json.Delim('}') {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
if _, err := d.Token(); err != io.EOF {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
d = json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if d.Decode(out) != nil {
|
||||
return nil, ErrOnlineToken
|
||||
}
|
||||
return seen, nil
|
||||
}
|
||||
|
||||
func decodeOnlineStatus(raw []byte) (statusWire, error) {
|
||||
var out statusWire
|
||||
seen, err := decodeOnlineObject(raw, &out)
|
||||
if err != nil || out.Active == nil || (!*out.Active && len(seen) != 1) {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
if *out.Active {
|
||||
if len(seen) != 10 {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
for _, field := range []string{"active", "client_id", "token_type", "iss", "sub", "aud", "scope", "iat", "exp", "jti"} {
|
||||
if !seen[field] {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *OnlineClient) request(ctx context.Context, endpoint, raw string) (statusWire, error) {
|
||||
var out statusWire
|
||||
if c == nil || ctx == nil || ctx.Err() != nil || len(raw) == 0 || len(raw) > 32768 || (endpoint != "/introspect" && endpoint != "/revoke") {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
ca, err := readOnlineFile(c.admission.CAFile, false)
|
||||
if err != nil {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
roots := x509.NewCertPool()
|
||||
if !roots.AppendCertsFromPEM(ca) {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
secretRaw, err := readOnlineFile(c.admission.SecretFile, true)
|
||||
if err != nil {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
secret := strings.TrimSuffix(strings.TrimSuffix(string(secretRaw), "\n"), "\r")
|
||||
if secret == "" || strings.ContainsAny(secret, "\r\n\x00") {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
form := url.Values{"token": {raw}, "token_type_hint": {"access_token"}}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.admission.Issuer+endpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.SetBasicAuth(url.QueryEscape(c.admission.ClientID), url.QueryEscape(secret))
|
||||
transport := &http.Transport{Proxy: nil, TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}, DisableKeepAlives: true, DialContext: c.dialContext}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return ErrOnlineToken }}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK || response.Header.Get("Cache-Control") != "no-store" {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 65537))
|
||||
if err != nil || len(body) > 65536 {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
if endpoint == "/revoke" {
|
||||
if len(body) != 0 {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
media, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
|
||||
if err != nil || media != "application/json" {
|
||||
return out, ErrOnlineToken
|
||||
}
|
||||
return decodeOnlineStatus(body)
|
||||
}
|
||||
|
||||
// Check verifies expected identity before accessing the private client credential,
|
||||
// sends a new authenticated introspection and rechecks lifetime after the network.
|
||||
func (c *OnlineClient) Check(ctx context.Context, raw string, expected ExpectedIdentity) (CurrentToken, error) {
|
||||
if c == nil || ctx == nil || ctx.Err() != nil {
|
||||
return CurrentToken{}, ErrOnlineToken
|
||||
}
|
||||
identity, err := c.verify(raw, expected, c.now().UTC())
|
||||
if err != nil {
|
||||
return CurrentToken{}, ErrOnlineToken
|
||||
}
|
||||
status, err := c.request(ctx, "/introspect", raw)
|
||||
if err != nil || status.Active == nil || !*status.Active {
|
||||
return CurrentToken{}, ErrOnlineToken
|
||||
}
|
||||
expires, _ := exactUnix(identity.claims["exp"])
|
||||
if status.ClientID != c.admission.ClientID || status.TokenType != "Bearer" || status.Issuer != identity.Issuer || status.Subject != identity.Subject || status.Audience != identity.Audience || status.TokenID != identity.TokenID || status.Scope != identity.claims["scope"] || status.IssuedAt != identity.IssuedAt.Unix() || status.ExpiresAt != expires.Unix() {
|
||||
return CurrentToken{}, ErrOnlineToken
|
||||
}
|
||||
end := c.now().UTC()
|
||||
if end.Before(identity.CheckedAt) {
|
||||
return CurrentToken{}, ErrOnlineToken
|
||||
}
|
||||
// A slow lookup cannot retain expired facts or a withdrawn JWT trust key.
|
||||
return c.verify(raw, expected, end)
|
||||
}
|
||||
|
||||
// Revoke performs cleanup only for the exact opaque token. An empty 200 is not
|
||||
// proof: a separate authenticated introspection must confirm inactive-for-client.
|
||||
// The caller retains original grant ownership and uncertainty/retry duties.
|
||||
func (c *OnlineClient) Revoke(ctx context.Context, raw string) (InactiveForClient, error) {
|
||||
if c == nil {
|
||||
return InactiveForClient{}, ErrOnlineToken
|
||||
}
|
||||
if _, err := c.request(ctx, "/revoke", raw); err != nil {
|
||||
return InactiveForClient{}, ErrOnlineToken
|
||||
}
|
||||
return c.ObserveInactive(ctx, raw)
|
||||
}
|
||||
|
||||
// ObserveInactive is read-only. Unavailability or a malformed response is never
|
||||
// interpreted as revocation; the negative result remains scoped to this client.
|
||||
func (c *OnlineClient) ObserveInactive(ctx context.Context, raw string) (InactiveForClient, error) {
|
||||
if c == nil {
|
||||
return InactiveForClient{}, ErrOnlineToken
|
||||
}
|
||||
status, err := c.request(ctx, "/introspect", raw)
|
||||
if err != nil || status.Active == nil || *status.Active {
|
||||
return InactiveForClient{}, ErrOnlineToken
|
||||
}
|
||||
return InactiveForClient{Issuer: c.admission.Issuer, ClientID: c.admission.ClientID, TokenDigest: tokenDigest(raw), CheckedAt: c.now().UTC()}, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue