Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 24 additions & 21 deletions libraries/authority-client/go/README.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,43 @@
# Authority Go client

Codefly generates the RPC bindings in `gen/`. The `workcontext` package adds a
non-verifying projection using Core's maintained decoder and scope containment:
Codefly generates the RPC bindings in `gen/`. The `workcontext` package
authenticates the Work Context the hop forwarded and projects its claims:

```go
import claims "github.com/codefly-dev/authority/libraries/authority-client/go/workcontext"

// The reader requires incoming metadata and the run-delivered hop proof.
md, _ := metadata.FromIncomingContext(ctx)
c, err := claims.ReadClaims(md, proof)
// Handle err, compare c.Audience() to this module's delivered audience, then:
c, err := claims.Authenticate(md, proof, audience, trust)
// err wraps claims.ErrUnauthenticated and names the reason.
allowed := c.Allows(resourceKind, action, exactResourceID)
```

A module does not call this directly: `hop.Verifier`
(`libraries/hop/go`) resolves `trust` from the authority's published keys and
runs it on every call. `Authenticate` is the generated-code-free check it rests
on, which the shared conformance suite also drives against both hosts.

It checks, in order: this run's hop proof, exactly one token and the stamp's
SHA-256 digest of it (`stamp.Context`); then Core's `Inspector`: canonical
decode, the issuer pin, the Ed25519 signature under the published key the token
names, the audience is this module, not-before and expiry within Core's skew,
and structure and attenuation. It consumes nothing and asks the authority
nothing: liveness is the authority's Check the hop made, bound to this call by
the digest. Proof and digest alone authenticate nothing; the signature does.

`Scopes()` returns deep copies of the final actor's grants, or the owner's grants
when there is no actor. An empty final actor grants nothing. `Allows` evaluates
each candidate independently. An empty requested resource ID asks for the entire
kind; a restricted grant cannot satisfy it. Kind-wide grants cover named IDs.
Actions are literal: no admin implication, wildcard expansion, prefix matching
or union of one grant's actions with another's resources.

The reader checks a bounded canonical envelope and 64-byte signature shape, then
uses Core Decode. Go deliberately retains Core's stronger structural/schema and
attenuation checks; the Python and TypeScript readers only project readable
claims. All three agree on the shared Core-produced fixtures. None verifies a
signature, current time, live revisions or replay. Even a digest-bound context
may have been refused in warning mode. These claims alone never prove authority.
Keep audience, installed-definition, ownership, resource and live-visibility
checks at their existing owners. The exposed protobuf scopes are Core's current
public types during the authority migration; do not mutate returned copies and
expect to change the claims snapshot.
Actions are literal. `Context()` is a deep copy of the authenticated claims.

`../testdata/vectors.json` is written by `workcontext/vectors_test.go`
(`go test ./workcontext -args -update-vectors`): tokens and Go's verdict on
each, which the Python and TypeScript readers must reach too.

From this directory, run `GOWORK=off go test -race ./...` and `go vet ./...`.
The tests consume `../testdata/claims.json`, whose gateway-side oracle verifies
Core-minted tokens at a fixed fixture clock. Synthetic mutated tokens in unit
tests exercise parsing only and are not valid credentials.
The tests re-sign the bodies of `../testdata/claims.json` with a test key of
their own (Core refuses its public fixture key outside its kit).

Handwritten code lives outside `gen/`, the directory Codefly replaces. The root
module scaffold is preserved by `codefly generate client`.
Expand Down
15 changes: 12 additions & 3 deletions libraries/authority-client/go/host/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,24 +71,33 @@ func targetFor(ctx context.Context, endpoint string, fallback func() (string, er
}
if !strings.Contains(endpoint, "://") {
if _, _, err := net.SplitHostPort(endpoint); err != nil {
return "", nil, fmt.Errorf("authority/endpoint %q is neither host:port nor a URL", endpoint)
return "", nil, fmt.Errorf("authority/endpoint is neither host:port nor a URL (value not shown: it may carry a credential)")
}
return plaintext(endpoint, codefly.IsLocal(), mesh)
}
u, err := url.Parse(endpoint)
if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
return "", nil, fmt.Errorf("authority/endpoint %q is not an origin", endpoint)
return "", nil, fmt.Errorf("authority/endpoint %s is not an origin: it must carry no credential, path, query or fragment", hostOnly(u))
}
switch u.Scheme {
case "https":
return withPort(u, "443"), credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}), nil
case "http", "grpc", "tcp":
return plaintext(withPort(u, "80"), codefly.IsLocal(), mesh)
default:
return "", nil, fmt.Errorf("authority/endpoint %q names scheme %q; the host is reached over http:// (h2c) or https://", endpoint, u.Scheme)
return "", nil, fmt.Errorf("authority/endpoint %s names scheme %q; the host is reached over http:// (h2c) or https://", hostOnly(u), u.Scheme)
}
}

// hostOnly names an endpoint in an error by its host and port alone: never the
// raw value, its userinfo, path or query, which may carry a credential.
func hostOnly(u *url.URL) string {
if u == nil || u.Host == "" {
return "(unparseable)"
}
return u.Host
}

func withPort(u *url.URL, port string) string {
if u.Port() != "" {
return u.Host
Expand Down
27 changes: 27 additions & 0 deletions libraries/authority-client/go/host/host_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,30 @@ func TestUpstreamEndpointUsesTheSharedTransportGate(t *testing.T) {
}
}
}

// A credential pasted into authority/endpoint never reaches an error: a
// refusal names the host and port alone.
func TestARefusalNeverEchoesTheEndpointValue(t *testing.T) {
const secret = "s3cr3t-token"
for _, endpoint := range []string{
"https://user:" + secret + "@authority.example:8443",
"https://authority.example:8443/?token=" + secret,
"https://authority.example:8443/" + secret,
"https://authority.example:8443/#" + secret,
"ftp://authority.example:21?k=" + secret,
"ftp://" + secret + "@authority.example:21",
secret + "-not-an-address",
"https://[" + secret,
} {
_, _, err := Target(endpoint, nil)
if err == nil {
t.Fatalf("%q accepted", endpoint)
}
if strings.Contains(err.Error(), secret) {
t.Fatalf("the refusal echoes the endpoint's credential: %v", err)
}
}
if _, _, err := Target("https://user:"+secret+"@authority.example:8443", nil); err == nil || !strings.Contains(err.Error(), "authority.example:8443") {
t.Fatalf("the refusal names the host and port: %v", err)
}
}
90 changes: 59 additions & 31 deletions libraries/authority-client/go/workcontext/claims.go
Original file line number Diff line number Diff line change
@@ -1,74 +1,102 @@
// Package workcontext projects an already hop-bound Work Context. It is not a
// verifier: readable claims prove neither authentication nor live authority.
// Package workcontext authenticates the Work Context the hop forwarded to a
// module, and projects its claims. Authenticate is the one check a module's
// reading rests on: nothing here decodes a token it has not authenticated.
package workcontext

import (
"encoding/base64"
"crypto/ed25519"
"errors"
"fmt"
"time"

"github.com/codefly-dev/authority/libraries/authority-client/go/stamp"
"google.golang.org/grpc/metadata"
"strings"

basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
core "github.com/codefly-dev/core/workcontext"
"google.golang.org/protobuf/proto"
)

// ErrUnreadableContext names a token that cannot be projected. Core's structural
// refusals are also retained in the error chain; no signature is verified.
var ErrUnreadableContext = errors.New("authority: unreadable context")
// ErrUnauthenticated is a forwarded Work Context the module must not believe.
// The error chain names the reason: Core's refusal (signature, issuer,
// audience, window, structure) or the stamp's (proof, digest).
var ErrUnauthenticated = errors.New("authority: the forwarded Work Context is not authenticated")

// Trust is the authority a module believes: the issuer every capability must
// name and that issuer's published verification keys by key id, exactly as
// the authority interface's Keys answers them. It is resolved from the host,
// never typed in.
type Trust struct {
Issuer string
Keys map[string]ed25519.PublicKey
// Now is the clock the window is judged at; nil means time.Now.
Now func() time.Time
}

// Claims owns a snapshot of the audience and Core's effective scopes.
// It can be read concurrently. Its zero value grants nothing.
// Claims owns a snapshot of an authenticated context. It can be read
// concurrently. Its zero value grants nothing.
type Claims struct {
audience string
scopes []*basev0.WorkScopeV1
context *basev0.WorkContextV1
}

// ReadClaims verifies the hop proof and digest before reading the bound token, using Core's maintained decoder.
// In addition to canonical envelope checks, Go retains Core's structural checks;
// the Python/TypeScript projections do not implement that structural validator.
// None of these readers verifies signatures, time, live revisions or replay.
func ReadClaims(md metadata.MD, proof string) (*Claims, error) {
// Authenticate returns the claims of the Work Context the hop forwarded with
// md to this module (audience), after two checks, in order:
//
// 1. the stamp: md carries this run's hop proof, exactly one token, and the
// stamp's digest of that token, so the token is the one the authority's
// Check admitted for this call (stamp.Context);
// 2. the token itself, with Core's Inspector: canonical decode, the issuer
// pin, the Ed25519 signature under the published key the token names, the
// audience is this module, not-before and expiry within Core's skew, and
// the chain's structure and attenuation.
//
// It consumes nothing and asks the authority nothing: liveness (revision,
// seal, single-use) is the authority's Check the hop made, which the digest
// binds to this call. A proof alone, or a digest alone, authenticates nothing:
// whoever holds the proof can write both, but cannot sign.
func Authenticate(md metadata.MD, proof, audience string, trust Trust) (*Claims, error) {
if audience == "" {
return nil, fmt.Errorf("%w: no audience: a module authenticates a context for itself", ErrUnauthenticated)
}
token, err := stamp.Context(md, proof)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrUnreadableContext, err)
}

if len(token) == 0 || len(token) > core.MaxTokenSize {
return nil, fmt.Errorf("%w: expected a bounded context", ErrUnreadableContext)
return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err)
}
payload, signature, ok := strings.Cut(token, ".")
if !ok || strings.Contains(signature, ".") {
return nil, fmt.Errorf("%w: expected payload.signature", ErrUnreadableContext)
}
for n, segment := range []string{payload, signature} {
decoded, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil || base64.RawURLEncoding.EncodeToString(decoded) != segment || (n == 1 && len(decoded) != 64) {
return nil, fmt.Errorf("%w: noncanonical envelope or invalid signature length", ErrUnreadableContext)
}
inspector := core.Inspector{Issuer: trust.Issuer, Keys: trust.Keys, Now: trust.Now}
if _, err := inspector.Inspect(token, audience); err != nil {
return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err)
}
decoded, err := core.Decode(token)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrUnreadableContext, err)
return nil, fmt.Errorf("%w: %w", ErrUnauthenticated, err)
}
context := decoded.Context()
scopes := context.GetAuthorityScopes()
if chain := context.GetActorChain(); len(chain) > 0 {
scopes = chain[len(chain)-1].GetGrantedScopes()
}
return &Claims{audience: context.GetAudience(), scopes: scopes}, nil
return &Claims{audience: context.GetAudience(), scopes: scopes, context: context}, nil
}

// Audience is data to compare with this module's delivered audience.
// Audience is the module the context is addressed to: the one Authenticate
// was asked for.
func (c *Claims) Audience() string {
if c == nil {
return ""
}
return c.audience
}

// Context returns a deep copy of the authenticated claims.
func (c *Claims) Context() *basev0.WorkContextV1 {
if c == nil || c.context == nil {
return nil
}
return proto.Clone(c.context).(*basev0.WorkContextV1)
}

// Scopes returns deep copies; changing them cannot change later Allows results.
func (c *Claims) Scopes() []*basev0.WorkScopeV1 {
if c == nil {
Expand Down
Loading