key-cape/adapters/go/tokenstatus/pkg/tokenstatus/client.go
tegwick 08c083bae9
Some checks failed
Authentication acceptance / acceptance (push) Failing after 59s
Authentication acceptance / provider-contract (push) Successful in 13s
Build and Publish Container Image / build-and-push (push) Successful in 31s
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
2026-10-18 11:36:26 +02:00

379 lines
13 KiB
Go

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
}