From 2cac3d31fc1f9acbe93284487b547eb4258f3777 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:55:29 -0400 Subject: [PATCH 1/2] Prepare MCP acquisition protocol checks and bound delegated capability expiry --- README.md | 12 +- docs/mcp-acquisition.md | 161 +++++++++++++++ .../authority-core/go/authority/delegation.go | 19 +- .../go/authority/delegation_expiry_test.go | 74 +++++++ .../authority-core/go/oauthwire/binding.go | 78 +++++++ .../authority-core/go/oauthwire/discovery.go | 119 +++++++++++ .../authority-core/go/oauthwire/wire_test.go | 190 ++++++++++++++++++ libraries/conformance/go/conformance.go | 8 +- 8 files changed, 656 insertions(+), 5 deletions(-) create mode 100644 docs/mcp-acquisition.md create mode 100644 libraries/authority-core/go/oauthwire/binding.go create mode 100644 libraries/authority-core/go/oauthwire/discovery.go create mode 100644 libraries/authority-core/go/oauthwire/wire_test.go diff --git a/README.md b/README.md index 6467e62..9e81ff6 100644 --- a/README.md +++ b/README.md @@ -931,10 +931,18 @@ revoked, and a negative lifetime is malformed. The core takes the lifetime in seconds, the wire's unit, and bounds it before converting, so a lifetime too large for a duration is the ceiling, never one that wraps to zero. Past it, `Issue` from the delegation is refused and `GetDelegation` answers `active: false`. Unlike a -revoke, an expiry advances no revision: a capability issued before it lives out -its own lifetime, at most `MaxTTL`. The edge requires a lifetime of a +revoke, an expiry advances no revision. A newly issued capability's signed expiry +is bounded by both `MaxTTL` and the delegation's remaining lifetime, including +when issuing again near the end; signing that crosses the bound is refused. +Core's verifier still applies its 30-second clock tolerance. Capabilities minted +before this bound was introduced retain their original signed expiry. The edge +requires a lifetime of a delegation made from a page ([services/edge](modules/authority/services/edge/README.md#a-page-delegates-a-run-2026-10-28)). +MCP client acquisition is still incomplete. The [acquisition work and owner +decisions](docs/mcp-acquisition.md) record the stateless discovery/PKCE checks, +the delegation reuse boundary, the refresh rule and the outstanding live proof. + Revoking a delegation advances the organization's authorization revision in the same state update. Previously issued capabilities, including descendants, then fail the ordinary core verifier. This is deliberately conservative: outstanding diff --git a/docs/mcp-acquisition.md b/docs/mcp-acquisition.md new file mode 100644 index 0000000..de6bc6b --- /dev/null +++ b/docs/mcp-acquisition.md @@ -0,0 +1,161 @@ +# MCP acquisition: implementation boundary and remaining decisions + +Tracking: [authority #52](https://github.com/codefly-dev/authority/issues/52). +Acquisition is **not enabled**: an ordinary MCP client still cannot complete the +flow against the local edge. The `oauthwire` package prepares stateless protocol +checks; it has no production caller yet. It is not an authorization server, +client registry, code store or token issuer. No public endpoints, issuer URLs, +resource identifiers or client identities have been provisioned by this change. + +## What can be reused + +[Authority #50](https://github.com/codefly-dev/authority/pull/50), merged at +`39369d67`, supplies expiring delegations in interface 0.3.5. +`libraries/authority-core/go/authority/delegation.go` already does the grant +intersection, current holdings/binding checks and pre/post-signing expiry checks. +The local issuer's `IssueRequest_Delegation` arm attests the actor's workload +credential before calling `IssueForDelegation`. Acquisition must use those paths, +not introduce another grant type or resolve permissions in HTTP handlers. + +That reuse has a concrete boundary: `Delegate` requires an installed composed +module; `IssueForDelegation` requires that same module and a declared binding +addressing the target audience. An arbitrary OAuth `client_id` satisfies neither. +Registration of an OAuth redirect URI does not attest a module or grant a binding. +Even borrowing a composed identity requires an owner decision about which +workload holds its credential and exercises the binding for the external client. + +Accounts already has OAuth discovery, public-client PKCE, resource binding and +rotating refresh in +[`oauth_authorization_server.go`](https://github.com/codefly-dev/module-saas-starter/blob/55563cda2450dc293beaaaa0392763701eb54be3/module/services/accounts/code/pkg/business/oauth_authorization_server.go). +Its redemption path calls `ConsumeClientAuthorizationCode` and +`minter.MintForClient` in one transaction, issuing an Accounts client session. +Its code lives in module `accounts`, importing `accounts/pkg/auth` and the +business service; it is not an independently consumable delegation adapter. +Copying its store or minter here would violate this repository's reuse boundary. +The host owner still needs to reconcile a shared implementation with Accounts; +local tests do not establish host adoption or SaaS parity. + +## Decisions required from the authority owner + +1. **The grantee and client discovery mechanism.** Does an external MCP client + become a first-class grantee, or use a specifically named composed actor? + If the latter, which actor, and who declares its solution bindings? + Should RFC 7591 dynamic registration be served for clients that attempt it? + #52 asks this; Accounts' source above explicitly omits DCR in favor of + operator-registered clients and Client ID Metadata Documents. Supporting DCR + and mapping registered clients into authority are separate decisions. +2. **Consent.** Must an external/background client's grant show a consent screen + naming the client, solution, access and expiry? The + [#50 handbook proposal](https://github.com/obin-ai/handbook/blob/main/proposals/2026-10-28-a-person-delegates-a-run-from-the-page.md) + says, “There is no consent screen: the person is already acting in that page, + the scopes are what they hold, and the delegation ends with the run.” + That rationale does not decide the background-client case. +3. **The route declaration.** Who supplies the complete public MCP URL for each + solution and the authority's public issuer URL, and through which SDK-resolved + projection? Runtime + [issue #50](https://github.com/codefly-dev/solution-runtime-go/issues/50) + records a missing public origin and a workspace-wide value that cannot name + two solutions. Historical + [PR #54](https://github.com/codefly-dev/solution-runtime-go/pull/54) + records the internal/public route confusion. Neither route spelling is an + answer for the new edge. The resource must equal the URL the client dials. + +These questions were presented to the owner in the #52 workspace. No answers +are assumed. Changes to `interfaces/` require a handbook proposal first. + +A composed agent acting **for a person** can use a delegation to its workload if +the binding exists. An agent acting **as itself** has no human grantor: the existing +`Issue(as=workload)` or installation arm is the relevant owner shape. A public +client's authorization-code flow must not manufacture a person for that case +or turn a client registration into workload attestation. + +## Protocol work prepared independently + +`libraries/authority-core/go/oauthwire` constructs RFC 9728 and RFC 8414 JSON +documents from supplied public URLs. The initial missing-token response is +HTTP 401 with `WWW-Authenticate: Bearer resource_metadata=""`. +Metadata must be reachable without sign-in. The protected-resource document +names exactly one solution URL and the authority issuer; Bearer transport is +the Authorization header. URL strings are preserved, never derived from Host, +Forwarded headers, internal service addresses or guessed frontend routes. + +The authorization-server document describes code + refresh, public-client +authentication (`none`) and S256. It may only be published when those handlers +actually exist. Registration and metadata-client support are advertised only +when explicitly configured and implemented. No OAuth scope vocabulary has been +invented: callers must declare only scopes their flow enforces. + +The PKCE checks use the RFC 7636 vector, strict unpadded SHA-256 challenges and +43–128 character verifiers. The eventual host adapter must additionally bind +each short-lived, single-use authorization code to the client, exact approved +redirect URI, resource, person, delegation and S256 challenge. Code consumption +and token issuance need the host's atomic redemption path. Duplicate parameters, +unapproved redirects, missing resource, `plain`, and reused codes must refuse. +PKCE by itself is not code storage or replay protection. + +Every authorization records one exact resource URL and its trusted solution +audience mapping. Code exchange, refresh and edge admission preserve it; another +solution must refuse before forwarding anything. `CheckResource` compares the +whole string without normalization. An omitted resource on token/refresh requests +inherits the stored value; it never makes the token unbound. Repeated resource +parameters refuse. The edge must distinguish acquired tokens from person sessions +before the existing session issuance path, so a resource-bound credential cannot +fall through into issuance for any module. + +OAuth scope strings are not WorkScopes. Grant derivation remains `Delegate`'s +intersection with the person's holdings, then `IssueForDelegation`'s intersection +with the current holdings and binding. Refresh preserves or narrows the original +scope ceiling; it cannot exchange the resource or add authority the person later +acquired outside that delegation. The scope mapping and storage adapter remain +to be connected after the grantee decision. + +## Lifetime and refresh + +The local authority defaults `MaxTTL` to 15 minutes. New delegated capabilities +now also cap their signed expiry at the delegation's end. Refresh must issue anew +using `Issue(as=delegation)`, preserving that delegation and its fixed expiry. +It cannot keep the person's browser session as a substitute for the delegation. +The core rechecks membership, holdings, binding, revocation and expiry each time. + +`oauthwire.ExpiresIn` reports whole seconds down to the earlier of the actual +capability expiry and delegation expiry. A zero/expired delegation refuses in +this acquisition flow even though server-side `Delegate(ttl_seconds=0)` remains +valid elsewhere. Refresh credentials must rotate atomically with replay detection +in the shared host storage path. Rotation never extends the delegation; expiry +or revocation requires fresh authorization by the person. + +Core verification retains its documented 30-second clock tolerance; clamping a +signed expiry is not an instantaneous cancellation guarantee. Previously minted +capabilities keep their signed expiry. The OAuth edge must enforce the recorded +access/delegation end before admitting a new request. An already admitted tool +call is allowed to finish under its existing request context; expiry does not +cancel it. Each later tool request or downstream authority issuance is checked +again. Runtime authority-call timeouts bound individual authority calls, not the +duration of the tool. Refresh never retries or resumes a tool call automatically. + +## Verification and the remaining acceptance run + +From the repository root, run: + +```sh +(cd libraries/authority-core/go && GOWORK=off go test -race -count=1 ./...) +(cd libraries/authority-core/go && GOWORK=off go vet ./...) +(cd modules/authority/services/local/code && GOWORK=off go test -race -count=1 ./pkg/business/issuer ./plugins/authority ./plugins/registry_test) +``` + +These cover wire shapes, S256, exact resource refusal and lifetime arithmetic, +the signed delegation-expiry bound, core's Work Context conformance kit, and the +shared authority suite through the local adapter. They do not exercise a live +OAuth server or prove the client acquisition definition of done. + +The acceptance recipe is pending the owner decisions and shared host adapter: +run the existing playground composition with `codefly run`, obtain the public +solution MCP URL from the route owner's SDK-resolved declaration, and configure +an MCP client with only that URL. Follow the 401 challenge to both documents; +sign in through the existing fixture session service; complete S256 authorization +and any required consent without copying credentials by hand. Verify initialize, +the five tools listed in #52 and their answers for that person. Present the same +access token to a second solution and require refusal. Exercise refresh after +capability expiry, and refusal after delegation expiry/revocation, without +extending the delegation. Record the client version and resolved route identities +with the run. No such run is claimed by this change. diff --git a/libraries/authority-core/go/authority/delegation.go b/libraries/authority-core/go/authority/delegation.go index 1656859..58c0f46 100644 --- a/libraries/authority-core/go/authority/delegation.go +++ b/libraries/authority-core/go/authority/delegation.go @@ -220,10 +220,22 @@ func (s *Service) IssueForDelegation(ctx context.Context, id string, actor Workl if err != nil { return Issued{}, err } + // Refreshing near a delegation's end must not buy another MaxTTL. Bound + // the signed capability itself, not just the OAuth response's expires_in. + ttl := clamp(r.TTL, s.cfg.MaxTTL) + if !d.ExpiresAt.IsZero() { + remaining := d.ExpiresAt.Sub(s.cfg.Clock()) + if remaining <= 0 { + return Issued{}, domain.Refuse("delegation %s expired", id) + } + if remaining < ttl { + ttl = remaining + } + } root, err := s.startFenced(ctx, f, workcontext.StartInput{ TenantID: d.Organization, OrganizationID: d.Organization, OwnerPrincipalID: d.Grantor, OwnerPrincipalKind: "human", TaskID: s.cfg.NewID(), Audience: audience.Principal, AuthorityScopes: scope.Limits{}.Project(through, scopes), - InstallationID: sealTo, TTL: clamp(r.TTL, s.cfg.MaxTTL), ReplayPolicy: workcontext.ReplayIdempotent, + InstallationID: sealTo, TTL: ttl, ReplayPolicy: workcontext.ReplayIdempotent, }) if err != nil { return Issued{}, err @@ -234,13 +246,16 @@ func (s *Service) IssueForDelegation(ctx context.Context, id string, actor Workl } token, signed, err := s.authority.Child(ctx, current, workcontext.ChildInput{ PrincipalID: am.Principal, PrincipalKind: "service", DelegationID: d.ID, GrantedScopes: scopes, Audience: audience.Principal, - TTL: clamp(r.TTL, s.cfg.MaxTTL), ReplayPolicy: workcontext.ReplayIdempotent, OperationBindingID: d.Organization + "/" + am.Name + "/" + r.Binding, + TTL: ttl, ReplayPolicy: workcontext.ReplayIdempotent, OperationBindingID: d.Organization + "/" + am.Name + "/" + r.Binding, }) if err != nil { return Issued{}, err } if again, err := s.Delegation(ctx, id, nil, &actor); err != nil || !again.Active(s.cfg.Clock()) { return Issued{}, domain.Refuse("delegation %s is revoked or expired", id) + } else if !again.ExpiresAt.IsZero() && signed.GetExpiresAtUnix() > again.ExpiresAt.Unix() { + // Time can advance while signing, or the store can shorten the grant. + return Issued{}, domain.Refuse("the issued capability would outlive delegation %s", id) } if signed.GetOperationBinding().GetRevision() != am.Generation { return Issued{}, domain.Refuse("actor binding changed during issuance") diff --git a/libraries/authority-core/go/authority/delegation_expiry_test.go b/libraries/authority-core/go/authority/delegation_expiry_test.go index ce46dee..5d2fe75 100644 --- a/libraries/authority-core/go/authority/delegation_expiry_test.go +++ b/libraries/authority-core/go/authority/delegation_expiry_test.go @@ -1,12 +1,15 @@ package authority_test import ( + "crypto" + "io" "testing" "time" "github.com/codefly-dev/authority/libraries/authority-core/go/authority" "github.com/codefly-dev/authority/libraries/authority-core/go/domain" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/workcontext" ) // A delegation asked for with a lifetime ends by itself (handbook proposal @@ -92,3 +95,74 @@ func TestADelegationWithALifetimeEndsByItself(t *testing.T) { } }) } + +// A refresh close to the delegation's end must not buy another MaxTTL of +// access. The signed expiry, not just OAuth's expires_in, carries the bound. +// Core verification still applies its documented clock-skew tolerance. +func TestDelegatedCapabilitiesDoNotOutliveTheirDelegation(t *testing.T) { + for _, requested := range []time.Duration{0, time.Hour, 10 * time.Second} { + t.Run(requested.String(), func(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + d, err := h.svc.Delegate(h.ctx, h.person("maya"), "a", + []*basev0.WorkScopeV1{owned(kind, h.principal(acme, "a"), []string{"read"})}, "one bounded run", 60) + if err != nil { + t.Fatal(err) + } + h.now = h.now.Add(30 * time.Second) + out := h.must(h.svc.IssueForDelegation(h.ctx, d.ID, authority.Workload{Module: "a"}, authority.IssueRequest{Binding: "store", TTL: requested})) + want := d.ExpiresAt.Unix() + if requested == 10*time.Second { + want = h.now.Add(requested).Unix() + } + if got := out.Context.GetExpiresAtUnix(); got != want { + t.Fatalf("capability expires at %d, want %d (delegation ends %v)", got, want, d.ExpiresAt) + } + if err := h.check(out); err != nil { + t.Fatal(err) + } + h.now = d.ExpiresAt.Add(workcontext.DefaultSkew) + if err := h.check(out); err == nil { + t.Fatal("a capability still admits work past its delegation's end and verifier clock tolerance") + } + }) + } +} + +type changeAfterSigning struct { + crypto.Signer + change func() +} + +func (s *changeAfterSigning) Sign(random io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { + signed, err := s.Signer.Sign(random, digest, opts) + if err == nil && s.change != nil { + change := s.change + s.change = nil + change() + } + return signed, err +} + +func TestDelegationEndIsRereadAfterSigning(t *testing.T) { + h := newHarness(t) + h.grant("a", "viewer", "maya") + d, err := h.svc.Delegate(h.ctx, h.person("maya"), "a", + []*basev0.WorkScopeV1{owned(kind, h.principal(acme, "a"), []string{"read"})}, "one bounded run", 60) + if err != nil { + t.Fatal(err) + } + h.cfg.Signer = &changeAfterSigning{Signer: h.cfg.Signer, change: func() { + d.ExpiresAt = h.now.Add(30 * time.Second) + if err := h.store.Write(h.ctx, func(tx authority.Tx) error { return tx.PutDelegation(d) }); err != nil { + t.Fatal(err) + } + }} + svc, err := authority.New(h.store, h.cfg) + if err != nil { + t.Fatal(err) + } + _, err = svc.IssueForDelegation(h.ctx, d.ID, authority.Workload{Module: "a"}, authority.IssueRequest{Binding: "store"}) + code(t, err, domain.Denied) + refused(t, err, "outlive") +} diff --git a/libraries/authority-core/go/oauthwire/binding.go b/libraries/authority-core/go/oauthwire/binding.go new file mode 100644 index 0000000..b778c42 --- /dev/null +++ b/libraries/authority-core/go/oauthwire/binding.go @@ -0,0 +1,78 @@ +package oauthwire + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "strings" + "time" +) + +const S256 = "S256" + +// ValidateChallenge checks the authorization request before a host records a +// code. Only the unpadded base64url encoding of a SHA-256 digest is accepted. +func ValidateChallenge(method, challenge string) error { + if method != S256 || len(challenge) != 43 { + return errors.New("an S256 PKCE challenge is required") + } + digest, err := base64.RawURLEncoding.Strict().DecodeString(challenge) + if err != nil || len(digest) != sha256.Size { + return errors.New("invalid S256 PKCE challenge") + } + return nil +} + +// VerifyPKCE verifies a code's recorded challenge on redemption. The host must +// also bind the code to the client, exact redirect URI, resource, person and +// delegation, expire it, and consume it atomically at most once. This function +// does not provide code storage or replay protection. +func VerifyPKCE(method, challenge, verifier string) error { + if err := ValidateChallenge(method, challenge); err != nil { + return err + } + if len(verifier) < 43 || len(verifier) > 128 || strings.IndexFunc(verifier, func(c rune) bool { + return !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("-._~", c)) + }) >= 0 { + return errors.New("invalid PKCE verifier") + } + digest := sha256.Sum256([]byte(verifier)) + got := base64.RawURLEncoding.EncodeToString(digest[:]) + if subtle.ConstantTimeCompare([]byte(got), []byte(challenge)) != 1 { + return errors.New("PKCE verifier does not match") + } + return nil +} + +// CheckResource compares the complete trusted resource binding, byte for byte. +// The authorization request must name one resource. On token/refresh requests +// an omitted resource is inherited from the stored binding by the caller, never +// interpreted as unbound. Repeated form parameters must be rejected by the HTTP +// adapter before calling this function. +func CheckResource(bound, presented string) error { + if bound == "" || presented == "" || bound != presented { + return errors.New("invalid_target: resource does not match the authorization") + } + return nil +} + +// ExpiresIn reports whole seconds remaining, bounded by BOTH the authority's +// actual issued capability and the delegation's fixed end. It never promises a +// lifetime longer than either. The host re-reads the live delegation and issues +// anew through Issue(as=delegation) on refresh; neither rotation nor this helper +// extends the delegation. Expiry reporting does not cancel an in-flight call. +func ExpiresIn(now, capabilityEnd, delegationEnd time.Time) (int64, error) { + if capabilityEnd.IsZero() || delegationEnd.IsZero() { + return 0, errors.New("MCP acquisition requires a capability and an expiring delegation") + } + end := capabilityEnd + if delegationEnd.Before(end) { + end = delegationEnd + } + seconds := int64(end.Sub(now) / time.Second) + if seconds <= 0 { + return 0, errors.New("authorization has no whole second remaining") + } + return seconds, nil +} diff --git a/libraries/authority-core/go/oauthwire/discovery.go b/libraries/authority-core/go/oauthwire/discovery.go new file mode 100644 index 0000000..8dbfca7 --- /dev/null +++ b/libraries/authority-core/go/oauthwire/discovery.go @@ -0,0 +1,119 @@ +// Package oauthwire supplies stateless MCP acquisition protocol checks. It does +// not register clients, store codes, sign in people, derive grants or mint tokens. +// Hosts must supply those operations and trusted, resolved public URLs before +// publishing discovery. Nothing in this package enables an HTTP endpoint. +package oauthwire + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" +) + +// Discovery names public endpoints supplied by their route owner, never by an +// unauthenticated request's Host or forwarding headers. Resource is the complete +// URL the MCP client dials; none of these strings is normalized or reconstructed. +type Discovery struct { + Resource, ResourceMetadataURL, Issuer, AuthorizationEndpoint, TokenEndpoint string + // Set these only when the host actually implements the corresponding flow. + RegistrationEndpoint string + ClientMetadataSupported bool + Scopes []string + // Local permits HTTP on a loopback host for a local composition. + Local bool +} + +// ProtectedResourceMetadata is RFC 9728's document for one solution. +type ProtectedResourceMetadata struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + BearerMethods []string `json:"bearer_methods_supported"` + Scopes []string `json:"scopes_supported,omitempty"` +} + +// AuthorizationServerMetadata describes an authorization-code + PKCE server +// with refresh for public clients. A host must implement these capabilities +// before publishing the document; discovery is not an implementation of them. +type AuthorizationServerMetadata struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + ResponseTypes []string `json:"response_types_supported"` + GrantTypes []string `json:"grant_types_supported"` + TokenEndpointAuthMethods []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethods []string `json:"code_challenge_methods_supported"` + Scopes []string `json:"scopes_supported,omitempty"` + RegistrationEndpoint string `json:"registration_endpoint,omitempty"` + ClientMetadataSupported bool `json:"client_id_metadata_document_supported,omitempty"` +} + +// Documents validates the supplied route declarations before constructing either +// document. The caller serves JSON at the declared resource metadata URL and at +// the issuer's RFC 8414 well-known location, without requiring authentication. +func (d Discovery) Documents() (ProtectedResourceMetadata, AuthorizationServerMetadata, error) { + for name, raw := range map[string]string{ + "resource": d.Resource, "resource metadata": d.ResourceMetadataURL, + "issuer": d.Issuer, "authorization endpoint": d.AuthorizationEndpoint, + "token endpoint": d.TokenEndpoint, + } { + if err := publicURL(raw, d.Local); err != nil { + return ProtectedResourceMetadata{}, AuthorizationServerMetadata{}, fmt.Errorf("%s: %w", name, err) + } + } + if d.RegistrationEndpoint != "" { + if err := publicURL(d.RegistrationEndpoint, d.Local); err != nil { + return ProtectedResourceMetadata{}, AuthorizationServerMetadata{}, fmt.Errorf("registration endpoint: %w", err) + } + } + resource, _ := url.Parse(d.Resource) + metadata, _ := url.Parse(d.ResourceMetadataURL) + if resource.Scheme != metadata.Scheme || resource.Host != metadata.Host { + return ProtectedResourceMetadata{}, AuthorizationServerMetadata{}, errors.New("resource metadata must be on the resource's edge origin") + } + for _, s := range d.Scopes { + // RFC 6749 scope-token: printable ASCII excluding quote and backslash. + if s == "" || strings.IndexFunc(s, func(c rune) bool { return c < 0x21 || c > 0x7e || c == '"' || c == '\\' }) >= 0 { + return ProtectedResourceMetadata{}, AuthorizationServerMetadata{}, errors.New("a scope must be one OAuth scope-token") + } + } + return ProtectedResourceMetadata{ + Resource: d.Resource, AuthorizationServers: []string{d.Issuer}, + BearerMethods: []string{"header"}, Scopes: append([]string(nil), d.Scopes...), + }, AuthorizationServerMetadata{ + Issuer: d.Issuer, AuthorizationEndpoint: d.AuthorizationEndpoint, TokenEndpoint: d.TokenEndpoint, + ResponseTypes: []string{"code"}, GrantTypes: []string{"authorization_code", "refresh_token"}, + TokenEndpointAuthMethods: []string{"none"}, CodeChallengeMethods: []string{S256}, + Scopes: append([]string(nil), d.Scopes...), RegistrationEndpoint: d.RegistrationEndpoint, + ClientMetadataSupported: d.ClientMetadataSupported, + }, nil +} + +// Challenge is the WWW-Authenticate value for an unauthenticated MCP request's +// 401. No error is attached to the initial request without a credential (6750 +// section 3.1). Bad credentials may additionally be reported as invalid_token by +// the host. Resource metadata is a URL, not a token or a session identifier. +func (d Discovery) Challenge() (string, error) { + if _, _, err := d.Documents(); err != nil { + return "", err + } + return `Bearer resource_metadata="` + d.ResourceMetadataURL + `"`, nil +} + +func publicURL(raw string, local bool) error { + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" || u.Opaque != "" || u.User != nil || + u.RawQuery != "" || u.ForceQuery || strings.Contains(raw, "#") || + strings.ContainsAny(raw, "\"\\") || strings.IndexFunc(raw, func(c rune) bool { return c <= 0x20 || c >= 0x7f }) >= 0 { + return errors.New("a resolved public URL must be absolute, with no userinfo, query, fragment or unsafe characters") + } + if u.Scheme == "https" { + return nil + } + ip := net.ParseIP(u.Hostname()) + if local && u.Scheme == "http" && (u.Hostname() == "localhost" || ip != nil && ip.IsLoopback()) { + return nil + } + return errors.New("a public URL requires HTTPS, or HTTP loopback in a local composition") +} diff --git a/libraries/authority-core/go/oauthwire/wire_test.go b/libraries/authority-core/go/oauthwire/wire_test.go new file mode 100644 index 0000000..1cc9c18 --- /dev/null +++ b/libraries/authority-core/go/oauthwire/wire_test.go @@ -0,0 +1,190 @@ +package oauthwire_test + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-core/go/oauthwire" +) + +func discovery(t *testing.T) oauthwire.Discovery { + t.Helper() + s := httptest.NewTLSServer(nil) + t.Cleanup(s.Close) + return oauthwire.Discovery{ + Resource: s.URL + "/v1/example/mcp", + ResourceMetadataURL: s.URL + "/.well-known/oauth-protected-resource/v1/example/mcp", + Issuer: s.URL, AuthorizationEndpoint: s.URL + "/authorize", TokenEndpoint: s.URL + "/token", + } +} + +func TestDiscoveryPreservesTheDeclaredResourceAndIssuer(t *testing.T) { + d := discovery(t) + r, a, err := d.Documents() + if err != nil { + t.Fatal(err) + } + challenge, err := d.Challenge() + if err != nil || challenge != `Bearer resource_metadata="`+d.ResourceMetadataURL+`"` { + t.Fatalf("challenge %q: %v", challenge, err) + } + raw, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var resource map[string]any + if err := json.Unmarshal(raw, &resource); err != nil { + t.Fatal(err) + } + if resource["resource"] != d.Resource || resource["authorization_servers"].([]any)[0] != d.Issuer || resource["bearer_methods_supported"].([]any)[0] != "header" { + t.Fatalf("resource metadata: %s", raw) + } + raw, err = json.Marshal(a) + if err != nil { + t.Fatal(err) + } + var server map[string]any + if err := json.Unmarshal(raw, &server); err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{"issuer": d.Issuer, "authorization_endpoint": d.AuthorizationEndpoint, "token_endpoint": d.TokenEndpoint} { + if server[key] != want { + t.Fatalf("%s: got %v, want %s", key, server[key], want) + } + } + if server["code_challenge_methods_supported"].([]any)[0] != "S256" || server["token_endpoint_auth_methods_supported"].([]any)[0] != "none" { + t.Fatalf("public client PKCE metadata: %s", raw) + } + for _, field := range []string{"registration_endpoint", "client_id_metadata_document_supported", "scopes_supported"} { + if _, exists := server[field]; exists { + t.Fatalf("unconfigured capability %s advertised", field) + } + } + // No guessed route layout, case folding, path cleaning or trailing slash + // removal: these are different resource identifiers under RFC 8707. + for _, path := range []string{"/proxy/example/mcp/", "/v1/%65xample/mcp", "/v1/EXAMPLE/mcp"} { + d.Resource = d.Issuer + path + r, _, err := d.Documents() + if err != nil || r.Resource != d.Resource { + t.Fatalf("declared resource changed: %+v %v", r, err) + } + } +} + +func TestDiscoveryRefusesIncompleteOrUnsafeDeclarations(t *testing.T) { + base := discovery(t) + for name, change := range map[string]func(*oauthwire.Discovery){ + "missing resource": func(d *oauthwire.Discovery) { d.Resource = "" }, + "missing issuer": func(d *oauthwire.Discovery) { d.Issuer = "" }, + "missing authorize": func(d *oauthwire.Discovery) { d.AuthorizationEndpoint = "" }, + "missing token": func(d *oauthwire.Discovery) { d.TokenEndpoint = "" }, + "relative metadata": func(d *oauthwire.Discovery) { d.ResourceMetadataURL = "/metadata" }, + "other origin metadata": func(d *oauthwire.Discovery) { + d.ResourceMetadataURL = strings.Replace(d.ResourceMetadataURL, "https:", "http:", 1) + d.Local = true + }, + "query": func(d *oauthwire.Discovery) { d.Resource += "?token=secret" }, + "empty query": func(d *oauthwire.Discovery) { d.Resource += "?" }, + "fragment": func(d *oauthwire.Discovery) { d.Issuer += "#" }, + "userinfo": func(d *oauthwire.Discovery) { d.Issuer = strings.Replace(d.Issuer, "://", "://person@", 1) }, + "header injection": func(d *oauthwire.Discovery) { d.ResourceMetadataURL += "\"\r\nX: injected" }, + "padding": func(d *oauthwire.Discovery) { d.Resource += " " }, + "remote http": func(d *oauthwire.Discovery) { d.Issuer = "http://issuer.invalid"; d.Local = true }, + "invalid registration": func(d *oauthwire.Discovery) { d.RegistrationEndpoint = "/register" }, + "scope list instead of token": func(d *oauthwire.Discovery) { d.Scopes = []string{"read write"} }, + } { + t.Run(name, func(t *testing.T) { + d := base + change(&d) + if _, _, err := d.Documents(); err == nil { + t.Fatal("invalid discovery accepted") + } + if challenge, err := d.Challenge(); err == nil || challenge != "" { + t.Fatalf("invalid discovery advertised: %q %v", challenge, err) + } + }) + } + base.Issuer = strings.Replace(base.Issuer, "https:", "http:", 1) + if _, _, err := base.Documents(); err == nil { + t.Fatal("HTTP allowed without local composition") + } + base.Local = true + if _, _, err := base.Documents(); err != nil { + t.Fatal(err) + } +} + +func TestPKCERFC7636VectorAndRefusals(t *testing.T) { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + const challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + if err := oauthwire.VerifyPKCE("S256", challenge, verifier); err != nil { + t.Fatal(err) + } + for _, method := range []string{"", "plain", "s256"} { + if err := oauthwire.VerifyPKCE(method, challenge, verifier); err == nil { + t.Fatalf("method %q accepted", method) + } + } + for _, bad := range []string{"", challenge + "=", challenge[:42], challenge + "A", strings.Repeat("~", 43), challenge[:42] + "N"} { + if err := oauthwire.ValidateChallenge("S256", bad); err == nil { + t.Fatalf("malformed or noncanonical challenge %q accepted", bad) + } + } + for _, bad := range []string{"", verifier[:42], strings.Repeat("a", 129), verifier + " ", verifier + "+", strings.Repeat("a", 43)} { + if err := oauthwire.VerifyPKCE("S256", challenge, bad); err == nil { + t.Fatalf("wrong verifier %q accepted", bad) + } + } + for _, n := range []int{43, 128} { + v := strings.Repeat("~", n) + h := sha256.Sum256([]byte(v)) + if err := oauthwire.VerifyPKCE("S256", base64.RawURLEncoding.EncodeToString(h[:]), v); err != nil { + t.Fatal(err) + } + } +} + +func TestResourceBindingNeverWidens(t *testing.T) { + d := discovery(t) + if err := oauthwire.CheckResource(d.Resource, d.Resource); err != nil { + t.Fatal(err) + } + for _, other := range []string{"", d.Issuer + "/v1/another/mcp", d.Issuer, d.Resource + "/", d.Resource + "?x=1", strings.ToUpper(d.Resource), strings.Replace(d.Resource, "example", "%65xample", 1)} { + if err := oauthwire.CheckResource(d.Resource, other); err == nil { + t.Fatalf("different resource %q admitted", other) + } + } + if err := oauthwire.CheckResource("", ""); err == nil { + t.Fatal("unbound credential admitted") + } +} + +func TestLifetimeIsTheEarlierEndRoundedDown(t *testing.T) { + now := time.Unix(1800000000, 0) + for _, tc := range []struct { + capability, delegation time.Duration + want int64 + }{ + {15 * time.Minute, 24 * time.Hour, 900}, + {15 * time.Minute, 30 * time.Second, 30}, + {1500 * time.Millisecond, time.Hour, 1}, + {time.Hour, 999 * time.Millisecond, 0}, + {0, time.Hour, 0}, {time.Hour, 0, 0}, {time.Hour, -time.Second, 0}, + } { + got, err := oauthwire.ExpiresIn(now, now.Add(tc.capability), now.Add(tc.delegation)) + if got != tc.want || (err != nil) != (tc.want == 0) { + t.Fatalf("%+v: got %d, %v", tc, got, err) + } + } + if _, err := oauthwire.ExpiresIn(now, now.Add(time.Hour), time.Time{}); err == nil { + t.Fatal("unbounded delegation accepted") + } + if _, err := oauthwire.ExpiresIn(now, time.Time{}, now.Add(time.Hour)); err == nil { + t.Fatal("missing capability accepted") + } +} diff --git a/libraries/conformance/go/conformance.go b/libraries/conformance/go/conformance.go index dafcafc..f25d7b4 100644 --- a/libraries/conformance/go/conformance.go +++ b/libraries/conformance/go/conformance.go @@ -898,7 +898,7 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt if _, err := delegate(-1); status.Code(err) != codes.InvalidArgument { t.Fatalf("a negative lifetime: %v", err) } - for _, ttl := range []int64{3600, 0} { + for _, ttl := range []int64{60, 3600, 0} { d, err := delegate(ttl) if err != nil { t.Fatalf("delegate for %d s: %v", ttl, err) @@ -911,6 +911,12 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt if err != nil || field(got, "expires_at_unix").Int() != at || !field(got, "active").Bool() { t.Fatalf("its module reads a delegation asked for %d s as ending at %d, standing %v: %v", ttl, field(got, "expires_at_unix").Int(), field(got, "active").Bool(), err) } + out := c.must(t, ctx, "host.authority.v1.AuthorityService", "Issue", map[string]any{ + "delegation": map[string]any{"delegation_id": str(d, "delegation_id")}, "audience": dc.Audience, + "actor": map[string]any{"credential": dc.Credential}, "operation_binding_id": dc.Binding}) + if expiry := field(msg(out, "context"), "expires_at_unix").Int(); at > 0 && expiry > at { + t.Fatalf("a capability expires at %d, past its delegation's end %d", expiry, at) + } c.must(t, grantor, "host.authority.v1.AuthorityService", "RevokeDelegation", map[string]any{"delegation_id": str(d, "delegation_id")}) } From 80cc7d6600ee805595ef2dcd478c389ed85f45ba Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:24:57 -0400 Subject: [PATCH 2/2] Add distinct client grantees to the shared delegation core --- README.md | 12 +- docs/mcp-acquisition.md | 138 ++++++++---- .../authority-core/go/authority/client.go | 191 ++++++++++++++++ .../go/authority/client_test.go | 206 ++++++++++++++++++ .../authority-core/go/authority/delegation.go | 73 +++++-- .../authority-core/go/authority/issue.go | 9 + .../authority-core/go/authority/lifecycle.go | 10 + .../authority-core/go/authority/service.go | 8 + .../authority-core/go/authority/store.go | 2 + libraries/authority-core/go/domain/domain.go | 18 +- .../authority-core/go/memstore/memstore.go | 22 +- .../authority-core/go/oauthwire/client.go | 77 +++++++ .../go/oauthwire/client_test.go | 39 ++++ 13 files changed, 730 insertions(+), 75 deletions(-) create mode 100644 libraries/authority-core/go/authority/client.go create mode 100644 libraries/authority-core/go/authority/client_test.go create mode 100644 libraries/authority-core/go/oauthwire/client.go create mode 100644 libraries/authority-core/go/oauthwire/client_test.go diff --git a/README.md b/README.md index 9e81ff6..fceeb5e 100644 --- a/README.md +++ b/README.md @@ -939,9 +939,15 @@ before this bound was introduced retain their original signed expiry. The edge requires a lifetime of a delegation made from a page ([services/edge](modules/authority/services/edge/README.md#a-page-delegates-a-run-2026-10-28)). -MCP client acquisition is still incomplete. The [acquisition work and owner -decisions](docs/mcp-acquisition.md) record the stateless discovery/PKCE checks, -the delegation reuse boundary, the refresh rule and the outstanding live proof. +MCP client acquisition is still incomplete. The owner settled a distinct client +identity with RFC 7591 registration, explicit background-client consent, and a +resource declared per solution and resolved by the SDK (issuer: the edge origin). +The shared core now registers scope-free client identities and extends the same +delegation to them, preserving the client actor through downstream module hops. +The [acquisition work and owner decisions](docs/mcp-acquisition.md) record the +tested core path, the host OAuth reuse boundary and the exact continuation point. +HTTP registration, consent, code/refresh exchange and the live proof are not yet +wired; the new core operations are not exposed through the public interface. Revoking a delegation advances the organization's authorization revision in the same state update. Previously issued capabilities, including descendants, then diff --git a/docs/mcp-acquisition.md b/docs/mcp-acquisition.md index de6bc6b..692f428 100644 --- a/docs/mcp-acquisition.md +++ b/docs/mcp-acquisition.md @@ -1,4 +1,4 @@ -# MCP acquisition: implementation boundary and remaining decisions +# MCP acquisition: settled decisions and remaining integration Tracking: [authority #52](https://github.com/codefly-dev/authority/issues/52). Acquisition is **not enabled**: an ordinary MCP client still cannot complete the @@ -14,15 +14,28 @@ resource identifiers or client identities have been provisioned by this change. `libraries/authority-core/go/authority/delegation.go` already does the grant intersection, current holdings/binding checks and pre/post-signing expiry checks. The local issuer's `IssueRequest_Delegation` arm attests the actor's workload -credential before calling `IssueForDelegation`. Acquisition must use those paths, -not introduce another grant type or resolve permissions in HTTP handlers. - -That reuse has a concrete boundary: `Delegate` requires an installed composed -module; `IssueForDelegation` requires that same module and a declared binding -addressing the target audience. An arbitrary OAuth `client_id` satisfies neither. -Registration of an OAuth redirect URI does not attest a module or grant a binding. -Even borrowing a composed identity requires an owner decision about which -workload holds its credential and exercises the binding for the external client. +credential before calling `IssueForDelegation`. The new client arm reuses the +same stored delegation and shared decisions; HTTP handlers must not resolve +permissions or introduce another grant type. + +The module arm remains unchanged: `Delegate` requires an installed composed +module and `IssueForDelegation` its attested workload and declared binding. +The core now also supports a client grantee on the **same stored delegation**: +`RegisterClient`, `ClientScopes`, `DelegateToClient` and +`IssueForClientDelegation`. Registration creates identity only. The owner stays +the person, and the actor chain carries `principal_kind=client` and the client's +own ID through later solution/module hops. A client borrows no installation, +workload credential or binding. The solution's installation seals the capability. + +Both grantee arms call the extracted `delegableScopes` calculation; the client +path reuses the existing holdings, scope decisions, fencing and core minter. +Its resource, solution audience, grantor, scopes and expiry are fixed by the +person's delegation and rechecked on issue. The caller of the internal core API +must authenticate client custody through code/refresh redemption: knowing the +public client ID or delegation ID is not proof. These core methods are not yet +exposed by RPC or HTTP. The existing in-memory authority adapter supports the new +identity record for tests; that is not a new OAuth code or refresh store and +is not evidence that Accounts has adopted the new grantee. Accounts already has OAuth discovery, public-client PKCE, resource binding and rotating refresh in @@ -35,39 +48,37 @@ Copying its store or minter here would violate this repository's reuse boundary. The host owner still needs to reconcile a shared implementation with Accounts; local tests do not establish host adoption or SaaS parity. -## Decisions required from the authority owner - -1. **The grantee and client discovery mechanism.** Does an external MCP client - become a first-class grantee, or use a specifically named composed actor? - If the latter, which actor, and who declares its solution bindings? - Should RFC 7591 dynamic registration be served for clients that attempt it? - #52 asks this; Accounts' source above explicitly omits DCR in favor of - operator-registered clients and Client ID Metadata Documents. Supporting DCR - and mapping registered clients into authority are separate decisions. -2. **Consent.** Must an external/background client's grant show a consent screen - naming the client, solution, access and expiry? The - [#50 handbook proposal](https://github.com/obin-ai/handbook/blob/main/proposals/2026-10-28-a-person-delegates-a-run-from-the-page.md) - says, “There is no consent screen: the person is already acting in that page, - the scopes are what they hold, and the delegation ends with the run.” - That rationale does not decide the background-client case. -3. **The route declaration.** Who supplies the complete public MCP URL for each - solution and the authority's public issuer URL, and through which SDK-resolved - projection? Runtime - [issue #50](https://github.com/codefly-dev/solution-runtime-go/issues/50) - records a missing public origin and a workspace-wide value that cannot name - two solutions. Historical - [PR #54](https://github.com/codefly-dev/solution-runtime-go/pull/54) - records the internal/public route confusion. Neither route spelling is an - answer for the new edge. The resource must equal the URL the client dials. - -These questions were presented to the owner in the #52 workspace. No answers -are assumed. Changes to `interfaces/` require a handbook proposal first. - -A composed agent acting **for a person** can use a delegation to its workload if -the binding exists. An agent acting **as itself** has no human grantor: the existing -`Issue(as=workload)` or installation arm is the relevant owner shape. A public -client's authorization-code flow must not manufacture a person for that case -or turn a client registration into workload attestation. +## Owner decisions, settled 2026-10-28 + +These are the owner's explicit decisions in the #52 working session: + +1. **An external client has its own identity; RFC 7591 is required.** It holds no + scopes of its own and receives all authority from the person's delegation. + Borrowing a composed module identity is rejected. Dynamic registration is + part of acceptance because clients such as `claude mcp add` attempt it; + hand-registration is not the URL-only flow. +2. **An explicit consent page at the edge names the solution, scopes and expiry.** + Consent supplements the expiring delegation. The + [#50 proposal](https://github.com/obin-ai/handbook/blob/main/proposals/2026-10-28-a-person-delegates-a-run-from-the-page.md) + decided against consent because the person was acting in that page. A + background client acts when the person is not watching; the premise differs, + so this adds consent for a different act without contradicting #50. +3. **The solution declares its resource once; the SDK resolves it.** Never + derive it from `PUBLIC_URL`. It must be exactly the MCP URL the client dials, + shared by discovery, authorization, refresh and admission. The authority + issuer is the edge's own origin. Historical + [runtime issue #50](https://github.com/codefly-dev/solution-runtime-go/issues/50) + and [PR #54](https://github.com/codefly-dev/solution-runtime-go/pull/54) explain + why the former derivations were wrong. + +The contract implications are recorded in the handbook proposal branch +[`authority-52-client-acquisition`](https://github.com/obin-ai/handbook/blob/authority-52-client-acquisition/proposals/2026-10-28-mcp-clients-use-their-own-delegated-identity.md). +No `interfaces/` or generated client has changed yet. The product choices are +settled; public API implementation and provider qualification are not. + +A composed agent acting **for a person** can still use a module delegation. +An agent acting **as itself** has no human grantor: it uses the existing workload +or installation arm. Public-client registration gives no headless authority. ## Protocol work prepared independently @@ -81,8 +92,9 @@ Forwarded headers, internal service addresses or guessed frontend routes. The authorization-server document describes code + refresh, public-client authentication (`none`) and S256. It may only be published when those handlers -actually exist. Registration and metadata-client support are advertised only -when explicitly configured and implemented. No OAuth scope vocabulary has been +actually exist. RFC 7591 registration is required by the owner; its endpoint must be advertised +when implemented. Metadata-client support remains optional and may be advertised +only when implemented. No OAuth scope vocabulary has been invented: callers must declare only scopes their flow enforces. The PKCE checks use the RFC 7636 vector, strict unpadded SHA-256 challenges and @@ -107,7 +119,7 @@ intersection with the person's holdings, then `IssueForDelegation`'s intersectio with the current holdings and binding. Refresh preserves or narrows the original scope ceiling; it cannot exchange the resource or add authority the person later acquired outside that delegation. The scope mapping and storage adapter remain -to be connected after the grantee decision. +to be connected to the host redemption path. ## Lifetime and refresh @@ -148,7 +160,7 @@ the signed delegation-expiry bound, core's Work Context conformance kit, and the shared authority suite through the local adapter. They do not exercise a live OAuth server or prove the client acquisition definition of done. -The acceptance recipe is pending the owner decisions and shared host adapter: +The acceptance recipe is pending the shared host adapter and HTTP/SDK wiring: run the existing playground composition with `codefly run`, obtain the public solution MCP URL from the route owner's SDK-resolved declaration, and configure an MCP client with only that URL. Follow the 401 challenge to both documents; @@ -159,3 +171,35 @@ access token to a second solution and require refusal. Exercise refresh after capability expiry, and refusal after delegation expiry/revocation, without extending the delegation. Record the client version and resolved route identities with the run. No such run is claimed by this change. + +## Exact continuation point + +The flow still stops at the first unauthenticated MCP request: the running edge +returns its existing session 401 without OAuth discovery. Core client identity +and client delegation are implemented and tested; they are not an HTTP DCR +endpoint. There are no issued OAuth codes, access handles or rotating refresh +credentials yet. The consent page and solution-owned SDK projection are not wired. + +The remaining reuse boundary is concrete. At Accounts commit `55563cda`: + +- `pkg/business/client_authorization.go` defines the authorization-code record + and `ClientAuthorizationCodeStore` callback transaction. +- `pkg/business/oauth_authorization_server.go:698` consumes that code and invokes + `minter.MintForClient` in the same transaction, bound to an Accounts session. +- `pkg/infra/postgres_client_authorization.go` implements that transaction. +- `pkg/auth/pg/client_session_store.go` holds client sessions and refresh rotation. + +These operations are not an independently importable delegation adapter. The +next implementation step is to extract/reconcile their common code redemption +and refresh logic with the host owner and supply an adapter calling the client +arm of the shared authority core. Accounts must consume the same extracted +implementation. A parallel local code/refresh store would violate AGENTS.md's +host-reuse rule, even if an OAuth smoke test passed. No such substitute has been +added. This work crosses into `module-saas-starter`; it cannot be delivered +solely by changing this authority PR. No issue or ticket has been opened for it. + +After that shared path exists: propose/generate the client arm of the public +interface, wire bounded RFC 7591 registration and explicit consent, resolve each +solution's declared resource and the edge origin via the SDK, wire discovery, +code/refresh exchange and exact-resource edge admission, then execute and record +the composed five-tool and second-solution refusal run. PR #53 remains draft. diff --git a/libraries/authority-core/go/authority/client.go b/libraries/authority-core/go/authority/client.go new file mode 100644 index 0000000..a1b485d --- /dev/null +++ b/libraries/authority-core/go/authority/client.go @@ -0,0 +1,191 @@ +package authority + +import ( + "context" + "errors" + "time" + + "github.com/codefly-dev/authority/libraries/authority-core/go/decide" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/oauthwire" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" + "github.com/codefly-dev/core/workcontext" +) + +// RegisterClient records public-client metadata; it creates no authority. The +// host bounds unauthenticated registration before calling this operation. +func (s *Service) RegisterClient(ctx context.Context, name string, redirects []string) (domain.Client, error) { + if err := oauthwire.ValidateRegistration(name, redirects); err != nil { + return domain.Client{}, domain.Malformed("%s", err) + } + c := domain.Client{ID: "client:" + s.cfg.NewID(), Name: name, RedirectURIs: redirects, Revision: 1} + if c.Name == "" { + c.Name = c.ID + } + err := s.store.Write(ctx, func(tx Tx) error { + if _, err := tx.Client(c.ID); err == nil { + return domain.NotNow("client identity collision") + } else if !errors.Is(err, domain.ErrNotFound) { + return err + } + for _, lookup := range []func() error{ + func() error { _, err := tx.Module(c.ID); return err }, + func() error { _, err := tx.Person(c.ID); return err }, + func() error { _, err := tx.InstallationByPrincipal(c.ID); return err }, + func() error { _, err := tx.TombstoneByPrincipal(c.ID); return err }, + } { + if err := lookup(); err == nil { + return domain.NotNow("client identity collides with an existing principal") + } else if !errors.Is(err, domain.ErrNotFound) { + return err + } + } + return tx.PutClient(c) + }) + return c, err +} + +func (s *Service) Client(ctx context.Context, id string) (domain.Client, error) { + var c domain.Client + err := s.store.Read(ctx, func(tx Tx) (err error) { c, err = tx.Client(id); return err }) + if err == nil && c.Disabled { + err = domain.Refuse("client is disabled") + } + return c, err +} + +// ClientScopes previews the same live ceiling Delegate uses, for the edge's +// consent page. Approval repeats these checks; a preview grants nothing. +func (s *Service) ClientScopes(ctx context.Context, who Person, audience string) (Scopes, error) { + var scopes Scopes + err := s.store.Read(ctx, func(tx Tx) error { + p, err := tx.Member(who.Organization, who.ID) + if err != nil { + return notFound(err) + } + m, err := tx.Module(audience) + if err != nil { + return notFound(err) + } + if m.Kind != domain.KindSolution { + return domain.Refuse("a client authorizes one solution") + } + scopes, err = delegableScopes(tx, p, m, nil) + return err + }) + return scope.Normalize(scopes), err +} + +// DelegateToClient extends the existing delegation, not a different grant. The +// host supplies the audience/resource pair from the solution's SDK-resolved +// declaration, never directly from an unauthenticated request. The signed-in +// person must explicitly approve scopes and expiry before this call. +func (s *Service) DelegateToClient(ctx context.Context, who Person, clientID, audience, resource string, scopes Scopes, purpose string, ttlSeconds int64) (domain.Delegation, error) { + if clientID == "" { + return domain.Delegation{}, domain.Malformed("a client delegation names its client") + } + return s.delegate(ctx, who, audience, clientID, resource, scopes, purpose, ttlSeconds) +} + +// IssueForClientDelegation uses the same delegation, live holdings and core +// minter as module delegation. clientID is authenticated by the host's code or +// refresh redemption, not by possession of a public client ID. No module's +// credential, principal, binding or installation is borrowed for this actor. +func (s *Service) IssueForClientDelegation(ctx context.Context, id, clientID, resource string, r IssueRequest) (Issued, error) { + var d domain.Delegation + var audience domain.Module + var scopes Scopes + var inst domain.Installation + var f fence + err := s.store.Read(ctx, func(tx Tx) error { + var err error + if d, err = tx.Delegation(id); err != nil { + return notFound(err) + } + if d.ClientID == "" || d.ClientID != clientID || d.Module != "" || !d.Active(s.cfg.Clock()) || d.ExpiresAt.IsZero() { + return domain.Refuse("client delegation is absent, revoked, expired or belongs to another client") + } + c, err := tx.Client(clientID) + if err != nil || c.Disabled { + return domain.Refuse("unknown or disabled client") + } + if err := oauthwire.CheckResource(d.Resource, resource); err != nil { + return domain.Refuse("%s", err) + } + if r.Binding != "" || r.Operation != "" && r.Operation != "invoke" || r.Audience != "" && r.Audience != d.Audience || r.Organization != "" && r.Organization != d.Organization { + return domain.Refuse("a client delegation cannot change solution, organization or exercise a workload binding") + } + if audience, err = tx.Module(d.Audience); err != nil { + return notFound(err) + } + if audience.Kind != domain.KindSolution { + return domain.Refuse("the delegated audience is no longer a solution") + } + p, err := tx.Member(d.Organization, d.Grantor) + if err != nil { + return domain.Refuse("the grantor is no longer a member") + } + h, err := delegableScopes(tx, p, audience, d.Scopes) + if err != nil { + return err + } + scopes, err = decide.Issue(r.Scopes, scope.Intersect(d.Scopes, h)) + if err != nil { + return err + } + if len(scopes) == 0 { + return domain.Refuse("the client delegation grants nothing the person still holds") + } + if err := decide.OwnerAware(audience, scopes); err != nil { + return err + } + if inst, err = installationOf(tx, d.Organization, audience.Name); err != nil { + return err + } + f, err = s.src.fenceIn(tx, d.Organization, d.Grantor, inst.ID) + return err + }) + if err != nil { + return Issued{}, err + } + ttl := min(clamp(r.TTL, s.cfg.MaxTTL), d.ExpiresAt.Sub(s.cfg.Clock())) + if ttl <= 0 { + return Issued{}, domain.Refuse("client delegation expired") + } + root, err := s.startFenced(ctx, f, workcontext.StartInput{ + TenantID: d.Organization, OrganizationID: d.Organization, OwnerPrincipalID: d.Grantor, OwnerPrincipalKind: "human", + TaskID: s.cfg.NewID(), Audience: audience.Principal, AuthorityScopes: scopes, + InstallationID: inst.ID, TTL: ttl, ReplayPolicy: workcontext.ReplayIdempotent, + }) + if err != nil { + return Issued{}, err + } + parent, err := s.Verify(ctx, root.Token, audience.Principal) + if err != nil { + return Issued{}, err + } + token, signed, err := s.authority.Child(ctx, parent, workcontext.ChildInput{ + PrincipalID: clientID, PrincipalKind: "client", DelegationID: d.ID, GrantedScopes: scopes, + Audience: audience.Principal, TTL: ttl, ReplayPolicy: workcontext.ReplayIdempotent, + }) + if err != nil { + return Issued{}, err + } + err = s.store.Read(ctx, func(tx Tx) error { + again, err := tx.Delegation(id) + if err != nil || !again.Active(s.cfg.Clock()) || again.Module != "" || again.ClientID != clientID || again.Resource != resource || again.Audience != audience.Principal || again.Grantor != d.Grantor || again.Organization != d.Organization || !scope.Attenuates(scopes, again.Scopes) || signed.GetExpiresAtUnix() > again.ExpiresAt.Unix() { + return domain.Refuse("client delegation changed while signing") + } + return nil + }) + if err != nil { + return Issued{}, err + } + if _, err := s.Verify(ctx, token, audience.Principal); err != nil { + return Issued{}, err + } + if time.Unix(signed.GetExpiresAtUnix(), 0).After(d.ExpiresAt) { + return Issued{}, domain.Refuse("capability outlives client delegation") + } + return Issued{token, signed}, nil +} diff --git a/libraries/authority-core/go/authority/client_test.go b/libraries/authority-core/go/authority/client_test.go new file mode 100644 index 0000000..6eb5478 --- /dev/null +++ b/libraries/authority-core/go/authority/client_test.go @@ -0,0 +1,206 @@ +package authority_test + +import ( + "testing" + "time" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" +) + +func clientGrant(t *testing.T) (*harness, domain.Client, domain.Delegation) { + t.Helper() + h := newHarness(t) + h.grant("a", "viewer", "maya") + c, err := h.svc.RegisterClient(h.ctx, "External client", []string{"https://client.invalid/callback"}) + if err != nil { + t.Fatal(err) + } + scopes, err := h.svc.ClientScopes(h.ctx, h.person("maya"), "a") + if err != nil { + t.Fatal(err) + } + d, err := h.svc.DelegateToClient(h.ctx, h.person("maya"), c.ID, "a", h.cfg.Issuer+"/mcp/a", scopes, "explicit consent", 3600) + if err != nil { + t.Fatal(err) + } + return h, c, d +} + +func TestClientActsAsItselfThroughThePersonsDelegation(t *testing.T) { + h, c, d := clientGrant(t) + if d.Module != "" || d.ClientID != c.ID || d.Audience != "a" { + t.Fatalf("wrong grantee: %+v", d) + } + root := h.must(h.svc.IssueForClientDelegation(h.ctx, d.ID, c.ID, d.Resource, authority.IssueRequest{})) + if err := h.check(root); err != nil { + t.Fatal(err) + } + chain := root.Context.GetActorChain() + if len(chain) != 1 || chain[0].GetPrincipalKind() != "client" || chain[0].GetPrincipalId() != c.ID || chain[0].GetDelegationId() != d.ID || root.Context.GetOwnerPrincipalId() != "maya" { + t.Fatalf("client or grantor identity lost: %v", root.Context) + } + child := h.must(h.svc.IssueChild(h.ctx, root.Token, authority.Workload{Module: "a"}, authority.IssueRequest{Binding: "store"})) + if err := h.check(child); err != nil { + t.Fatal(err) + } + chain = child.Context.GetActorChain() + if len(chain) != 2 || chain[0].GetPrincipalId() != c.ID || chain[0].GetPrincipalKind() != "client" || chain[1].GetPrincipalId() != "a" || child.Context.GetOwnerPrincipalId() != "maya" { + t.Fatalf("downstream call lost client provenance: %v", child.Context) + } + if _, _, err := h.svc.Check(h.ctx, root.Token, "b"); err == nil { + t.Fatal("another solution accepted the token") + } + if _, err := h.svc.IssueForWorkload(h.ctx, authority.Workload{Module: c.ID}, authority.IssueRequest{Binding: "store"}); err == nil { + t.Fatal("registration created a workload") + } + if _, err := h.svc.IssueForDelegation(h.ctx, d.ID, authority.Workload{Module: "a"}, authority.IssueRequest{Binding: "store"}); err == nil { + t.Fatal("a module borrowed the client's delegation") + } + if _, err := h.svc.RevokeDelegation(h.ctx, h.person("maya"), d.ID); err != nil { + t.Fatal(err) + } + if err := h.check(root); err == nil { + t.Fatal("revoked client token accepted") + } + if err := h.check(child); err == nil { + t.Fatal("revoked descendant accepted") + } +} + +func TestClientDelegationRefusesChangesAndRechecksLiveAuthority(t *testing.T) { + for name, change := range map[string]func(*harness, *domain.Client, *domain.Delegation, *authority.IssueRequest){ + "another client": func(_ *harness, c *domain.Client, _ *domain.Delegation, _ *authority.IssueRequest) { c.ID += "x" }, + "another resource": func(_ *harness, _ *domain.Client, d *domain.Delegation, _ *authority.IssueRequest) { d.Resource += "/" }, + "another solution": func(_ *harness, _ *domain.Client, _ *domain.Delegation, r *authority.IssueRequest) { r.Audience = "b" }, + "another organization": func(_ *harness, _ *domain.Client, _ *domain.Delegation, r *authority.IssueRequest) { + r.Organization = globex + }, + "a workload binding": func(_ *harness, _ *domain.Client, _ *domain.Delegation, r *authority.IssueRequest) { + r.Binding = "store" + }, + "headless operation": func(_ *harness, _ *domain.Client, _ *domain.Delegation, r *authority.IssueRequest) { + r.Operation = "headless" + }, + "expired delegation": func(h *harness, _ *domain.Client, d *domain.Delegation, _ *authority.IssueRequest) { + h.now = d.ExpiresAt + }, + "role lost": func(h *harness, _ *domain.Client, _ *domain.Delegation, _ *authority.IssueRequest) { + h.revoke("a", "viewer", "maya", "") + }, + "uninstalled": func(h *harness, _ *domain.Client, _ *domain.Delegation, _ *authority.IssueRequest) { + if err := h.svc.UninstallAsHost(h.ctx, acme, h.inst(acme, "a").ID); err != nil { + t.Fatal(err) + } + }, + "disabled client": func(h *harness, c *domain.Client, _ *domain.Delegation, _ *authority.IssueRequest) { + c.Disabled = true + if err := h.store.Write(h.ctx, func(tx authority.Tx) error { return tx.PutClient(*c) }); err != nil { + t.Fatal(err) + } + }, + } { + t.Run(name, func(t *testing.T) { + h, c, d := clientGrant(t) + r := authority.IssueRequest{} + change(h, &c, &d, &r) + if _, err := h.svc.IssueForClientDelegation(h.ctx, d.ID, c.ID, d.Resource, r); err == nil { + t.Fatal("invalid delegation issued") + } + }) + } +} + +func TestClientDelegationCannotGrowAtRefresh(t *testing.T) { + h, c, d := clientGrant(t) + first := h.must(h.svc.IssueForClientDelegation(h.ctx, d.ID, c.ID, d.Resource, authority.IssueRequest{})) + if first.Context.GetExpiresAtUnix() != h.now.Add(15*time.Minute).Unix() { + t.Fatal("local MaxTTL was not retained") + } + h.grant("a", "author", "maya") + // Newly held write is outside the already approved viewer delegation. + _, err := h.svc.IssueForClientDelegation(h.ctx, d.ID, c.ID, d.Resource, authority.IssueRequest{Scopes: authority.Scopes{owned(kind, h.principal(acme, "a"), []string{"write"})}}) + if err == nil { + t.Fatal("refresh widened the old consent") + } + h.now = d.ExpiresAt.Add(-30 * time.Second) + last := h.must(h.svc.IssueForClientDelegation(h.ctx, d.ID, c.ID, d.Resource, authority.IssueRequest{})) + if last.Context.GetExpiresAtUnix() != d.ExpiresAt.Unix() { + t.Fatal("refresh extended the delegation end") + } +} + +func TestClientDelegationChangesDuringSigningRefuse(t *testing.T) { + for _, change := range []string{"revoke", "narrow", "expire", "disable client"} { + t.Run(change, func(t *testing.T) { + h, c, d := clientGrant(t) + h.cfg.Signer = &changeAfterSigning{Signer: h.cfg.Signer, change: func() { + if err := h.store.Write(h.ctx, func(tx authority.Tx) error { + switch change { + case "revoke": + d.Revoked = true + case "narrow": + d.Scopes = nil + case "expire": + d.ExpiresAt = h.now + case "disable client": + c.Disabled = true + return tx.PutClient(c) + } + return tx.PutDelegation(d) + }); err != nil { + t.Fatal(err) + } + }} + svc, err := authority.New(h.store, h.cfg) + if err != nil { + t.Fatal(err) + } + if _, err := svc.IssueForClientDelegation(h.ctx, d.ID, c.ID, d.Resource, authority.IssueRequest{}); err == nil { + t.Fatal("changed grant issued a capability") + } + }) + } +} + +func TestClientAndModulePrincipalsCannotCollide(t *testing.T) { + h, c, _ := clientGrant(t) + m := domain.Module{Name: "pretender", Principal: c.ID, Kind: domain.KindModule, Generation: 1, ResourceKinds: authority.Scopes{sc("pretender", "read")}} + if _, err := h.svc.ApplyModule(h.ctx, m); err == nil { + t.Fatal("module claimed a client's principal") + } + m.Principal = "client:collision" + if _, err := h.svc.ApplyModule(h.ctx, m); err != nil { + t.Fatal(err) + } + h.cfg.NewID = func() string { return "collision" } + svc, err := authority.New(h.store, h.cfg) + if err != nil { + t.Fatal(err) + } + if _, err := svc.RegisterClient(h.ctx, "client", []string{"https://client.invalid/callback"}); err == nil { + t.Fatal("client claimed a module's principal") + } +} + +func TestClientStoreUsesTheAuthorityTransactionAndClonesMetadata(t *testing.T) { + h, c, _ := clientGrant(t) + c.RedirectURIs[0] = "https://modified.invalid/callback" + stored, err := h.svc.Client(h.ctx, c.ID) + if err != nil || stored.RedirectURIs[0] == c.RedirectURIs[0] { + t.Fatalf("caller mutated stored client: %+v %v", stored, err) + } + err = h.store.Write(h.ctx, func(tx authority.Tx) error { + if err := tx.PutClient(c); err != nil { + return err + } + return domain.Refuse("rollback") + }) + if err == nil { + t.Fatal("transaction unexpectedly committed") + } + again, err := h.svc.Client(h.ctx, c.ID) + if err != nil || again.RedirectURIs[0] != stored.RedirectURIs[0] { + t.Fatalf("client write escaped rollback: %+v %v", again, err) + } +} diff --git a/libraries/authority-core/go/authority/delegation.go b/libraries/authority-core/go/authority/delegation.go index 58c0f46..74fd6b0 100644 --- a/libraries/authority-core/go/authority/delegation.go +++ b/libraries/authority-core/go/authority/delegation.go @@ -19,16 +19,30 @@ import ( // bounded here, before any conversion, so no host's conversion can overflow // it into one that never ends. func (s *Service) Delegate(ctx context.Context, who Person, module string, scopes Scopes, purpose string, ttlSeconds int64) (domain.Delegation, error) { + return s.delegate(ctx, who, module, "", "", scopes, purpose, ttlSeconds) +} + +func (s *Service) delegate(ctx context.Context, who Person, target, clientID, resource string, scopes Scopes, purpose string, ttlSeconds int64) (domain.Delegation, error) { var d domain.Delegation err := s.store.Write(ctx, func(tx Tx) error { p, err := tx.Member(who.Organization, who.ID) if err != nil { return notFound(err) } - m, err := tx.Module(module) + m, err := tx.Module(target) if err != nil { return notFound(err) } + if clientID != "" { + c, err := tx.Client(clientID) + if err != nil || c.Disabled { + return domain.Refuse("unknown or disabled client") + } + if m.Kind != domain.KindSolution || resource == "" || ttlSeconds <= 0 { + return domain.Malformed("a client delegates an expiring grant for one declared solution resource") + } + } + if len(scopes) == 0 { return domain.Refuse("a delegation names what it grants") } @@ -43,31 +57,17 @@ func (s *Service) Delegate(ctx context.Context, who Person, module string, scope if inst, err := installationOf(tx, p.Organization, m.Name); err != nil || inst.Status != domain.Active { return domain.NotNow("module %q is not installed in organization %s, so nothing could issue from a delegation to it", m.Name, p.Organization) } - holdings, err := held(tx, p, m, scopes) + holdings, err := delegableScopes(tx, p, m, scopes) if err != nil { return err } - for _, b := range m.Bindings { - target, err := tx.Module(b.Audience) - if err != nil { - continue - } - there, _, _, err := throughSolutions(tx, p, target, []string{m.Principal}, scopes) - if err != nil { - return err - } - if m.Kind != domain.KindSolution { - if there, err = held(tx, p, target, scopes); err != nil { - return err - } - } - holdings = append(holdings, scope.Intersect(there, b.InvokeScopes)...) - holdings = append(holdings, scope.Intersect(there, b.LookupScopes)...) - } if !scope.Attenuates(scopes, holdings) { return domain.Refuse("%s delegates what they do not hold", p.ID) } d = domain.Delegation{ID: s.cfg.NewID(), Organization: p.Organization, Grantor: p.ID, Module: m.Name, Purpose: purpose, Scopes: scope.Normalize(scopes)} + if clientID != "" { + d.Module, d.ClientID, d.Audience, d.Resource = "", clientID, m.Principal, resource + } if ttlSeconds > 0 { lifetime := s.cfg.MaxDelegationTTL if ttlSeconds < int64(lifetime/time.Second) { @@ -78,8 +78,11 @@ func (s *Service) Delegate(ctx context.Context, who Person, module string, scope if err := tx.PutDelegation(d); err != nil { return err } - return s.journal(ctx, tx, domain.Event{Kind: domain.DelegationGranted, Organization: d.Organization, Actor: d.Grantor, Subject: d.ID, - Detail: map[string]string{"module": d.Module, "purpose": d.Purpose}}) + detail := map[string]string{"module": d.Module, "purpose": d.Purpose} + if clientID != "" { + detail = map[string]string{"client": clientID, "audience": d.Audience, "resource": resource, "purpose": purpose} + } + return s.journal(ctx, tx, domain.Event{Kind: domain.DelegationGranted, Organization: d.Organization, Actor: d.Grantor, Subject: d.ID, Detail: detail}) }) return d, err } @@ -262,3 +265,31 @@ func (s *Service) IssueForDelegation(ctx context.Context, id string, actor Workl } return Issued{token, signed}, nil } + +// delegableScopes is the one ceiling for a person's delegation: what they hold +// here and through this module/solution's declared bindings. +func delegableScopes(tx Tx, p domain.Person, m domain.Module, scopes Scopes) (Scopes, error) { + holdings, err := held(tx, p, m, scopes) + if err != nil { + return nil, err + } + for _, b := range m.Bindings { + target, err := tx.Module(b.Audience) + if err != nil { + continue + } + there, _, _, err := throughSolutions(tx, p, target, []string{m.Principal}, scopes) + if err != nil { + return nil, err + } + if m.Kind != domain.KindSolution { + if there, err = held(tx, p, target, scopes); err != nil { + return nil, err + } + } + holdings = append(holdings, scope.Intersect(there, b.InvokeScopes)...) + holdings = append(holdings, scope.Intersect(there, b.LookupScopes)...) + } + + return holdings, nil +} diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go index f2ce340..30bc8b1 100644 --- a/libraries/authority-core/go/authority/issue.go +++ b/libraries/authority-core/go/authority/issue.go @@ -664,6 +664,15 @@ func delegationCeiling(tx Tx, parent *basev0.WorkContextV1, ceiling Scopes) (Sco if err != nil { return nil, err } + if d.ClientID != "" { + // The client's identity is not a module. Keep its original grant + // on every descendant rather than trying to resolve it as one. + if d.Revoked || d.Module != "" || hop.GetPrincipalKind() != "client" || hop.GetPrincipalId() != d.ClientID || d.Grantor != parent.GetOwnerPrincipalId() || d.Organization != parent.GetOrganizationId() || !slices.Contains(solutionPrincipals(parent, parent.GetAudience()), d.Audience) { + return nil, domain.Refuse("the client delegation does not authorize this actor chain") + } + ceiling = scope.Intersect(ceiling, d.Scopes) + continue + } m, err := tx.Module(hop.GetPrincipalId()) if err != nil { return nil, notFound(err) diff --git a/libraries/authority-core/go/authority/lifecycle.go b/libraries/authority-core/go/authority/lifecycle.go index 310edf3..074cd9e 100644 --- a/libraries/authority-core/go/authority/lifecycle.go +++ b/libraries/authority-core/go/authority/lifecycle.go @@ -65,6 +65,11 @@ func (s *Service) ApplyModule(ctx context.Context, m domain.Module) ([]string, e if _, err := tx.Person(m.Principal); err == nil { return domain.Refuse("principal %q names a person; a module's principal is its own", m.Principal) } + if _, err := tx.Client(m.Principal); err == nil { + return domain.Refuse("principal %q names an external client; a module's principal is its own", m.Principal) + } else if !errors.Is(err, domain.ErrNotFound) { + return err + } if _, err := tx.InstallationByPrincipal(m.Principal); err == nil { return domain.Refuse("principal %q names an installed solution's principal; a module's principal is its own", m.Principal) } @@ -401,6 +406,11 @@ func (s *Service) install(ctx context.Context, authorize func(Tx) error, caller } else if err != nil && !errors.Is(err, domain.ErrNotFound) { return err } + if _, err := tx.Client(principal); err == nil { + return domain.Refuse("installation principal %q belongs to an external client", principal) + } else if !errors.Is(err, domain.ErrNotFound) { + return err + } inst = domain.Installation{ID: id, Organization: org, Target: target, Status: domain.Pending, Revision: existing.Revision + 1, AgentRole: agentRole, PrincipalID: principal, InstalledBy: installedBy} if approval != nil { diff --git a/libraries/authority-core/go/authority/service.go b/libraries/authority-core/go/authority/service.go index 5cfb3f4..89a5f28 100644 --- a/libraries/authority-core/go/authority/service.go +++ b/libraries/authority-core/go/authority/service.go @@ -284,6 +284,14 @@ func (src sources) PrincipalEpoch(ctx context.Context, principal string) (uint64 } func (src sources) epochIn(tx Tx, principal string) (uint64, error) { + if c, err := tx.Client(principal); err == nil { + if c.Disabled { + return 0, domain.Missing("disabled client %q", principal) + } + return c.Revision, nil + } else if !errors.Is(err, domain.ErrNotFound) { + return 0, err + } if m, err := tx.Module(principal); err == nil { return m.Generation, nil } diff --git a/libraries/authority-core/go/authority/store.go b/libraries/authority-core/go/authority/store.go index 82d5362..9b47318 100644 --- a/libraries/authority-core/go/authority/store.go +++ b/libraries/authority-core/go/authority/store.go @@ -77,4 +77,6 @@ type Tx interface { Delegation(id string) (domain.Delegation, error) Delegations() ([]domain.Delegation, error) PutDelegation(domain.Delegation) error + Client(id string) (domain.Client, error) + PutClient(domain.Client) error } diff --git a/libraries/authority-core/go/domain/domain.go b/libraries/authority-core/go/domain/domain.go index bf0c219..cd33501 100644 --- a/libraries/authority-core/go/domain/domain.go +++ b/libraries/authority-core/go/domain/domain.go @@ -127,11 +127,23 @@ type RoleGrant struct { InstallationID, Role, PersonID, TeamID string } -// Delegation is a person's grant to a module to act for them. +// Client is an external public client identity. Registration gives it no scopes, +// bindings or installation. Its authority comes only from a person's delegation. +type Client struct { + ID, Name string + RedirectURIs []string + Revision uint64 + Disabled bool +} + +// Delegation is a person's grant to a module or an external client to act for them. type Delegation struct { ID, Organization, Grantor, Module, Purpose string - Scopes []*basev0.WorkScopeV1 - Revoked bool + // A client delegation names exactly one solution audience and its declared + // public resource. Module and ClientID are mutually exclusive. + ClientID, Audience, Resource string + Scopes []*basev0.WorkScopeV1 + Revoked bool // ExpiresAt ends the delegation by itself; zero stands until revoked. ExpiresAt time.Time } diff --git a/libraries/authority-core/go/memstore/memstore.go b/libraries/authority-core/go/memstore/memstore.go index a058456..78f2a2a 100644 --- a/libraries/authority-core/go/memstore/memstore.go +++ b/libraries/authority-core/go/memstore/memstore.go @@ -32,6 +32,7 @@ type Store struct { ownerAware map[string]uint64 delegations map[string]domain.Delegation tombstones map[string]domain.Tombstone + clients map[string]domain.Client } // New is an empty store. @@ -41,6 +42,7 @@ func New() *Store { modules: map[string]domain.Module{}, installations: map[string]domain.Installation{}, grants: map[string][]domain.RoleGrant{}, revisions: map[string]uint64{}, personEpochs: map[string]uint64{}, grantEpochs: map[string]uint64{}, ownerAware: map[string]uint64{}, delegations: map[string]domain.Delegation{}, tombstones: map[string]domain.Tombstone{}, + clients: map[string]domain.Client{}, } } @@ -83,6 +85,7 @@ type state struct { ownerAware map[string]uint64 delegations map[string]domain.Delegation tombstones map[string]domain.Tombstone + clients map[string]domain.Client } func (s *Store) snapshot() state { @@ -92,13 +95,14 @@ func (s *Store) snapshot() state { } return state{modules: maps.Clone(s.modules), installations: maps.Clone(s.installations), grants: grants, revisions: maps.Clone(s.revisions), personEpochs: maps.Clone(s.personEpochs), grantEpochs: maps.Clone(s.grantEpochs), - ownerAware: maps.Clone(s.ownerAware), delegations: maps.Clone(s.delegations), tombstones: maps.Clone(s.tombstones)} + ownerAware: maps.Clone(s.ownerAware), delegations: maps.Clone(s.delegations), tombstones: maps.Clone(s.tombstones), clients: maps.Clone(s.clients)} } func (s *Store) restore(b state) { s.modules, s.installations, s.grants, s.revisions = b.modules, b.installations, b.grants, b.revisions s.personEpochs, s.grantEpochs, s.ownerAware, s.delegations = b.personEpochs, b.grantEpochs, b.ownerAware, b.delegations s.tombstones = b.tombstones + s.clients = b.clients } // PutOrganization, PutPerson, DeletePerson and PutOrgRole seed and change @@ -391,6 +395,22 @@ func (t tx) PutDelegation(d domain.Delegation) error { return nil } +func (t tx) Client(id string) (domain.Client, error) { + c, ok := t.s.clients[id] + if !ok { + return c, missing("client", id) + } + c.RedirectURIs = slices.Clone(c.RedirectURIs) + return c, nil +} + +func (t tx) PutClient(c domain.Client) error { + t.mustWrite() + c.RedirectURIs = slices.Clone(c.RedirectURIs) + t.s.clients[c.ID] = c + return nil +} + func clonePerson(p domain.Person) domain.Person { p.Roles, p.Teams = slices.Clone(p.Roles), slices.Clone(p.Teams) return p diff --git a/libraries/authority-core/go/oauthwire/client.go b/libraries/authority-core/go/oauthwire/client.go new file mode 100644 index 0000000..e98ccba --- /dev/null +++ b/libraries/authority-core/go/oauthwire/client.go @@ -0,0 +1,77 @@ +package oauthwire + +import ( + "errors" + "net" + "net/url" + "strconv" + "strings" + "unicode/utf8" +) + +// ValidateRegistration bounds the public-client metadata accepted by RFC 7591 +// registration. Names are untrusted display text, never a verified publisher. +// This profile supports code + refresh with token_endpoint_auth_method=none; +// adapters must reject requests for other grant types or authentication methods. +func ValidateRegistration(name string, redirects []string) error { + // client_name is optional in RFC 7591; absence does not require manual registration. + if !utf8.ValidString(name) || len(name) > 128 || strings.IndexFunc(name, func(r rune) bool { return r < 32 || r == 127 }) >= 0 { + return errors.New("client_name must contain at most 128 bytes of display text") + } + if len(redirects) == 0 || len(redirects) > 8 { + return errors.New("register 1–8 redirect URIs") + } + for _, raw := range redirects { + if _, err := redirectURL(raw); err != nil { + return err + } + } + return nil +} + +func redirectURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil || len(raw) > 2048 || u.Hostname() == "" || u.Opaque != "" || u.User != nil || strings.ContainsAny(raw, "#*\\\"") || strings.HasSuffix(u.Host, ":") || strings.IndexFunc(raw, func(r rune) bool { return r <= 32 || r >= 127 }) >= 0 { + return nil, errors.New("invalid redirect URI") + } + if port := u.Port(); port != "" { + p, err := strconv.Atoi(port) + if err != nil || p < 1 || p > 65535 { + return nil, errors.New("invalid redirect port") + } + } + ip := net.ParseIP(u.Hostname()) + if u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || ip != nil && ip.IsLoopback())) { + return nil, errors.New("redirect requires HTTPS or HTTP loopback") + } + return u, nil +} + +// AllowsRedirect matches the registered URI exactly, except RFC 8252's port +// variation for an HTTP loopback IP. Scheme, host, path, escaping and query stay +// bound; this never follows a redirect or fetches a client-supplied URI. +func AllowsRedirect(registered []string, candidate string) bool { + u, err := redirectURL(candidate) + if err != nil { + return false + } + for _, raw := range registered { + v, err := redirectURL(raw) + if err != nil { + continue + } + if raw == candidate { + return true + } + ip := net.ParseIP(v.Hostname()) + if v.Scheme == "http" && ip != nil && ip.IsLoopback() && u.Scheme == v.Scheme && u.Hostname() == v.Hostname() { + // Replace only the port, retaining all other components verbatim. + copy := *u + copy.Host = v.Host + if copy.String() == raw { + return true + } + } + } + return false +} diff --git a/libraries/authority-core/go/oauthwire/client_test.go b/libraries/authority-core/go/oauthwire/client_test.go new file mode 100644 index 0000000..ce9697f --- /dev/null +++ b/libraries/authority-core/go/oauthwire/client_test.go @@ -0,0 +1,39 @@ +package oauthwire_test + +import ( + "strings" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/oauthwire" +) + +func TestPublicClientRedirects(t *testing.T) { + registered := []string{"http://127.0.0.1/callback", "https://client.invalid/callback?fixed=1"} + if err := oauthwire.ValidateRegistration("Unverified client", registered); err != nil { + t.Fatal(err) + } + if err := oauthwire.ValidateRegistration("", registered); err != nil { + t.Fatalf("optional client_name was required: %v", err) + } + for _, uri := range []string{registered[0], registered[1], "http://127.0.0.1:49152/callback"} { + if !oauthwire.AllowsRedirect(registered, uri) { + t.Fatalf("registered redirect refused: %s", uri) + } + } + for _, uri := range []string{"http://127.0.0.1:49152/other", "http://127.0.0.2/callback", "http://localhost/callback", "http://127.0.0.1/%63allback", "https://client.invalid/callback", "https://client.invalid/callback?fixed=2", "https://client.invalid:443/callback?fixed=1", "https://client.invalid/callback?fixed=1#x", "https://client.invalid/callback?fixed=1&extra=1"} { + if oauthwire.AllowsRedirect(registered, uri) { + t.Fatalf("unregistered redirect accepted: %s", uri) + } + } + for _, uri := range []string{"/callback", "javascript:alert(1)", "http://client.invalid/callback", "https://person@client.invalid/callback", "https://*.invalid/callback", "https://client.invalid/callback#", "http://127.0.0.1:0/callback", "https://client.invalid:99999/callback", "https://client.invalid:/callback", "https://client.invalid/callback\r\n"} { + if err := oauthwire.ValidateRegistration("client", []string{uri}); err == nil { + t.Fatalf("unsafe registration accepted: %s", uri) + } + } + if err := oauthwire.ValidateRegistration("client", nil); err == nil { + t.Fatal("no callback accepted") + } + if err := oauthwire.ValidateRegistration(strings.Repeat("x", 129), registered); err == nil { + t.Fatal("unbounded name accepted") + } +}