Skip to content

feat(socrate): ServiceTokenWithScopes, a service token requested with scopes - #125

Merged
ovander merged 2 commits into
mainfrom
feat/service-token-scopes
Oct 11, 2026
Merged

ovander merged 2 commits into
mainfrom
feat/service-token-scopes

Conversation

@ovander

@ovander ovander commented Oct 11, 2026

Copy link
Copy Markdown
Owner

What and why

(*socrate.Client).ServiceToken requests its client_credentials token without a scope parameter, so Socrate issues its default api. Since Socrate v1.14.0, a client can be granted application-defined scopes (<namespace>:<name>, e.g. swingdrift:worker) in its allowed_scopes, but it has to request them. So a service using backendkit couldn't obtain the scope that a resource server's jwtauth.RequireScopes("swingdrift:worker") (v1.26.0) requires.

New API (additive):

func (c *Client) ServiceTokenWithScopes(ctx context.Context, scopes ...string) (token string, expiresAt time.Time, err error)
var ErrScopeNotGranted = errors.New("socrate: scope not granted")
  • Same contract as ServiceToken:
    • cached, and renewed within 30 s of expiry;
    • concurrent callers share one exchange;
    • requires ClientSecret;
    • sends the correlation ID;
    • the token is never logged or put in an error.
  • Cache per scope set: duplicates are removed and the set is sorted, so ("a","b") and ("b","a","a") share a token. Each set has its own lock, so different sets don't block each other. At most 16 sets are cached; beyond that the least recently used one is dropped.
  • Fails closed without a network call when:
    • no scope is given (use ServiceToken for the default);
    • a value isn't an RFC 6749 §3.3 scope-token;
    • ClientSecret is missing.
  • Checks what Socrate granted:
    • invalid_scope (a scope the client doesn't register) matches both ErrScopeNotGranted and *OAuthError.
    • A response whose scope lacks a requested scope is ErrScopeNotGranted, naming the missing scopes.
    • A response without scope means the request was granted as asked (RFC 6749 §5.1).
  • Socrate's behaviour, read from its v1.14.1 source: it issues exactly the requested scopes, without adding the default api. The docs tell callers to list api too when they need it.
  • ServiceToken is unchanged: it has its own cache and still sends no scope. The token exchange moved into a shared unexported helper with identical error texts. InvalidateServiceToken() now also clears the scoped tokens; existing callers have none.
  • Docs: the README socrate section, a "Which package" row and troubleshooting rows. docs/CLIENT-INTEGRATION.md gets the calling side: register the scope in Socrate, then call ServiceTokenWithScopes. The README's "app scopes on every grant" is corrected to "when the client requests them".

How it was tested

socrate/service_token_scopes_test.go (13 tests, httptest standing in for Socrate's /oauth/token) covers:

  • the scope parameter is sent normalised;
  • the cache is hit for the same set in any order, with separate tokens per set;
  • ServiceToken is unaffected;
  • renewal near expiry;
  • concurrent callers share one exchange (-race);
  • an invalid or empty scope list makes no request;
  • invalid_scope is recognisable;
  • a missing or non-string response scope, and null or an absent scope accepted;
  • LRU eviction;
  • no token in any error;
  • the correlation ID is sent;
  • invalidation clears the scoped tokens.

One test added to service_token_test.go pins down that ServiceToken accepts any scope type, as before.

  • go mod tidy && git diff --exit-code go.sum leaves go.sum unchanged
  • go build ./... passes
  • go vet ./... passes, also with -tags conformance
  • go test -race -count=1 -timeout=120s ./... passes, also ./socrate/... on the go 1.26.0 floor
  • golangci-lint run ./... (v2.14.0) reports no issue
  • govulncheck ./...: not run locally (vuln.go.dev is blocked here); CI runs it. No dependency change.
  • A line is added under ## [Unreleased] in CHANGELOG.md (### Added)

Compatibility

  • Exported-API change: yes, additive: (*Client).ServiceTokenWithScopes, ErrScopeNotGranted.
  • Behaviour change for existing callers: InvalidateServiceToken also clears scoped tokens, which they don't have. Nothing else.
  • Breaking change: none.

This PR and #124 (the changelog fix) both touch CHANGELOG.md. I'll merge main into whichever merges second.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA


Generated by Claude Code

…tokens

ServiceToken requests no scope, so Socrate issues its default (api) and a
service cannot obtain an application-defined scope (Socrate v1.14.0+) that a
peer requires with jwtauth.RequireScopes. ServiceTokenWithScopes sends the
scopes, with ServiceToken's contract, cached per normalised scope set (at
most 16, LRU). Scopes are validated as RFC 6749 scope-tokens before any
request; invalid_scope and a response scope lacking a requested scope match
the new ErrScopeNotGranted. InvalidateServiceToken also drops scoped tokens.
The client_credentials exchange is shared through an unexported helper;
ServiceToken's behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA
Resolve the CHANGELOG conflict with #124: Added (this change) before
Changed (docs), both under Unreleased.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA
@ovander
ovander merged commit ff1cdd6 into main Oct 11, 2026
8 checks passed
ovander pushed a commit that referenced this pull request Oct 11, 2026
Moves [Unreleased] under [1.27.0] - 2026-10-11 (#125 ServiceTokenWithScopes, #123 docs), updates the compare links, the README install snippet and the guide's version note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA
@ovander ovander mentioned this pull request Oct 11, 2026
2 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants