Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a14bfc-9643-7872-aa12-98326dcf25e7
379 lines
13 KiB
Go
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
|
|
}
|