From eff6ec4deab09897cf301fc636d7116e6aaedec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:03:59 +0000 Subject: [PATCH] feat(socrate): add ServiceTokenWithScopes for scoped service-account 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 Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA --- CHANGELOG.md | 13 + README.md | 23 +- docs/CLIENT-INTEGRATION.md | 36 ++- socrate/bff.go | 29 +- socrate/client.go | 67 +++- socrate/service_token_scopes.go | 173 +++++++++++ socrate/service_token_scopes_test.go | 449 +++++++++++++++++++++++++++ socrate/service_token_test.go | 15 + 8 files changed, 774 insertions(+), 31 deletions(-) create mode 100644 socrate/service_token_scopes.go create mode 100644 socrate/service_token_scopes_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f65663..5c53470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to backendkit are documented here. Format: ## [Unreleased] +### Added +- **`socrate.Client.ServiceTokenWithScopes` and `socrate.ErrScopeNotGranted`: a service-account + token carrying given scopes.** `ServiceToken` requests no scope, so Socrate issues its default + (`api`) and a service could not obtain an application-defined scope (`swingdrift:worker`, + Socrate v1.14.0 or later) that a peer requires with `jwtauth.RequireScopes`. + `ServiceTokenWithScopes(ctx, scopes...)` sends them as `scope`, with `ServiceToken`'s contract + (cached, renewed within 30 s of expiry, one shared exchange, needs `ClientSecret`, token never + in an error). The cache is per scope set (deduplicated and sorted, at most 16 sets, least + recently used evicted) and separate from `ServiceToken`'s, which is unchanged. No scope or a + malformed RFC 6749 scope-token is refused without a request. `invalid_scope` from Socrate, or a + response `scope` lacking a requested scope, matches `ErrScopeNotGranted` (`invalid_scope` is + also an `*OAuthError`). `InvalidateServiceToken` now also drops the scoped tokens. + ## [1.26.1] - 2026-10-11 Patch release on the **v1** line: `MemoryStore` keeps a logout tombstone for an hour, as diff --git a/README.md b/README.md index e8e6af2..7dce784 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ to Socrate (`bff`, `pep`) and `ailang` for `aigateway`. | Guarantee a tenant on tenant-scoped routes | [`httpware.RequireTenant`](#httpware) | | Gate routes by role/permission | [`httpware.RBAC`](#httpware) | | Reserve a route group to a service account by OAuth scope | [`jwtauth.RequireScopes`](#jwtauth) | +| Get a service-account token carrying a given scope, to call such a route | [`socrate.Client.ServiceTokenWithScopes`](#socrate) | | Gate routes or features by commercial plan | [`tiering`](#tiering) | | Return consistent JSON errors | [`apierror`](#apierror) | | Call Socrate to manage users, apps, tokens, or security | [`socrate`](#socrate) | @@ -536,7 +537,14 @@ forwards the request id (`ctxutil.GetRequestID`, set by `httpware.RequestID`) as `ServiceToken(ctx)` returns that token and its expiry, for calling another service that accepts Socrate tokens as the application itself. When that service refuses the token with 401 before its expiry (a token revoked at Socrate), `InvalidateServiceToken()` drops the cached token so the next -call exchanges a new one. +call exchanges a new one. `ServiceToken` requests no scope, so Socrate issues its default (`api`). +`ServiceTokenWithScopes(ctx, scopes...)` requests the given scopes instead, for a service whose +routes have `jwtauth.RequireScopes`: it is cached per scope set (order and duplicates do not matter, +at most 16 sets, least recently used dropped first), separately from `ServiceToken`'s token, and +`InvalidateServiceToken()` clears both. It refuses an empty list or a malformed scope without a +network call, and returns an error matching `ErrScopeNotGranted` when Socrate answers +`invalid_scope` (the error is also an `*OAuthError`) or grants fewer scopes than requested. Socrate +issues exactly the scopes requested, without `api`, so list `api` too when the token needs it. > **`AppID` is required for all service-account methods.** Service-account tokens carry > `sub=app:{id}` and the Socrate admin routes cannot resolve the app ID at runtime without it. @@ -563,6 +571,13 @@ inv, err := client.InviteUserAsService(ctx, socrate.ServiceInviteRequest{ Role: "editor", }) +// A token carrying an application-defined scope, to call another service's +// worker API (the scope is in this client's allowed_scopes, Socrate v1.14.0+): +tok, _, err := client.ServiceTokenWithScopes(ctx, "swingdrift:worker") +if errors.Is(err, socrate.ErrScopeNotGranted) { + // the scope is not registered for this client in Socrate +} + // Conflict handling: if errors.Is(err, socrate.ErrUserAlreadyExists) { // handle duplicate registration @@ -679,7 +694,8 @@ auth := jwtauth.New(jwksURL, issuer, logger, Called with no non-empty scope it rejects every token (fail closed). Without `RequireScopes`, a malformed `scope`/`scp` claim is ignored, so existing callers see no change. Socrate v1.14.0 or later issues application-defined scopes such as `my-api:worker` (`:`) to - the clients whose `allowed_scopes` list them, on every grant. A service account's + the clients whose `allowed_scopes` list them, when they request them (the calling service + uses `socrate.Client.ServiceTokenWithScopes`). A service account's (`client_credentials`) token has `sub` = `app:`, no `role` or `app_roles`, and `aud` starting with its own client ID; since Socrate v1.13.0, with `AUDIENCE_MODE=dual`, its registered audiences follow, as for user tokens. @@ -1192,7 +1208,8 @@ scripts/conformance-local.sh | **Service-account call errors with "AppID must be set"** | Set `AppID` in `ClientConfig` (`SOCRATE_APP_ID`). The `/api/admin/apps` lookup needs a human-admin JWT, so a service token cannot resolve the app ID at runtime. | | **Rate limiter never limits** | `RateLimiter` keys on the tenant UUID and lets requests through when none is present. Place `rl.Handler` **after** `auth.Handler` so `tenant_id` is already in context. | | **`tiering.Gate` always allows / always denies** | The gate reads the plan from context (`ctxutil.GetUserPlan`); confirm auth runs before the gate and that your `PlanRegistry` contains the plan names you check. Unknown plans normalise to the lowest tier. | -| **`403 insufficient_scope` from `jwtauth`** | The middleware has `RequireScopes` and the token lacks one of the scopes (the `WWW-Authenticate` header names them). Grant the scope to the calling client in Socrate (`allowed_scopes`; an application-defined `:` scope needs Socrate v1.14.0 or later), then check that the token's `scope` claim carries it. | +| **`403 insufficient_scope` from `jwtauth`** | The middleware has `RequireScopes` and the token lacks one of the scopes (the `WWW-Authenticate` header names them). Grant the scope to the calling client in Socrate (`allowed_scopes`; an application-defined `:` scope needs Socrate v1.14.0 or later), have the caller request it (`ServiceTokenWithScopes`; `ServiceToken` requests none and gets `api`), then check that the token's `scope` claim carries it. | +| **`ServiceTokenWithScopes` returns `ErrScopeNotGranted`** | Socrate answered `invalid_scope` (the scope is not in the client's `allowed_scopes`, or Socrate is older than v1.14.0 for an application-defined scope), or its response granted fewer scopes than requested. Register the scope for the client in Socrate. | | **BFF answers 403 on `POST`/`PUT`/`DELETE`** | The request lacks the session's CSRF token in `X-CSRF-Token`. Give the SPA the token (for example in your `/bff/session` response) and send it on every unsafe request. | | **Every `pep` check answers 401** | `pep` runs before `jwtauth`, so there is no user token in context. Mount `auth.Handler` first. | | **`pep` answers `503 policy_unavailable` at startup** | Socrate's decision point was unreachable before any decision told the process the mode. Check `SOCRATE_BASE_URL`, `SOCRATE_CLIENT_SECRET` and `SOCRATE_APP_ID`; set `FailOpenWhenModeUnknown` only if proceeding is acceptable while the mode is unknown. | diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index dc9cf73..9ff5144 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -256,7 +256,28 @@ A route group that only a service account may call (a worker API, say) also passes `jwtauth.RequireScopes("my-api:worker")` (backendkit v1.26.0+): a valid token without every listed scope gets **403** `insufficient_scope`. Socrate v1.14.0 or later issues such application-defined scopes (`:`) -to the clients whose `allowed_scopes` list them. +to the clients whose `allowed_scopes` list them, when they request them. + +On the calling side (backendkit later than v1.26.1): +first register the scope in the calling client's `allowed_scopes` in Socrate +(v1.14.0 or later), then request it with `ServiceTokenWithScopes`; the plain +`ServiceToken` requests no scope and gets Socrate's default, `api`: + +```go +tok, _, err := client.ServiceTokenWithScopes(ctx, "swingdrift:worker") +if errors.Is(err, socrate.ErrScopeNotGranted) { + // invalid_scope from Socrate (scope not registered for this client), or a + // response granting fewer scopes than requested: fix the client in Socrate. +} +if err != nil { + return err +} +req.Header.Set("Authorization", "Bearer "+tok) +``` + +The token carries exactly the scopes requested (not `api` as well: list it +when needed). It is cached per scope set, like `ServiceToken`'s, and +`InvalidateServiceToken()` drops it too. Also enable `gormlogger.WithSQLRedaction()` in production so interpolated SQL parameter values (which may contain PII) stay out of logs. These controls landed @@ -383,7 +404,9 @@ near-expiry) and calls Socrate as the *app itself*, no human involved. Used for backend-initiated actions: onboarding, magic links, background sync. `ServiceToken(ctx)` returns that token and its expiry, for a backend that calls another service with its own identity. If that service answers 401 before the token expires, call `InvalidateServiceToken()` and then -`ServiceToken(ctx)` again: the cached token is dropped and a new one exchanged. +`ServiceToken(ctx)` again: the cached token is dropped and a new one exchanged. For a service +that requires a scope of its own, use `ServiceTokenWithScopes(ctx, "my-api:worker")` (see +the route group reserved to a service account, §4). ```go inv, err := client.InviteUserAsService(ctx, socrate.ServiceInviteRequest{ @@ -492,7 +515,8 @@ automatically from `client_id` (cached). |--------|------|---------|-------| | `Decide(ctx, DecideRequest)` | M2M | `*Decision` | asks Socrate's policy decision point; `ErrPolicyUnavailable` on 503, with the mode kept in the `Decision`. Usually called through `pep` (§10). | | `ServiceToken(ctx)` | M2M | `string, time.Time` | the app's own `client_credentials` access token and its real expiry (the token's `exp`, else `expires_in` counted from the request), to call another service that accepts Socrate tokens (`sub=app:{id}`). The cached token of the service-account calls, exchanged again within 30 s of expiry; concurrent callers share one exchange. Needs `ClientSecret`. | -| `InvalidateServiceToken()` | M2M | — | drops the cached service-account token, so the next `ServiceToken` or service-account call exchanges a new one (after a 401 for a token revoked before its `exp`). No network call; thread-safe. | +| `ServiceTokenWithScopes(ctx, scopes...)` | M2M | `string, time.Time` | as `ServiceToken`, but requests the given scopes (`scope=` space-joined), e.g. an application-defined `swingdrift:worker` registered in the client's `allowed_scopes` (Socrate v1.14.0+). Cached per scope set (deduplicated and sorted; at most 16 sets, least recently used dropped), separately from `ServiceToken`'s token. No scope or a malformed RFC 6749 scope-token is refused without a network call. `invalid_scope`, or a response `scope` lacking a requested scope, matches `ErrScopeNotGranted` (`invalid_scope` is also an `*OAuthError`); other refusals are an `*OAuthError`. Needs `ClientSecret`. | +| `InvalidateServiceToken()` | M2M | — | drops the cached service-account token, and every token cached by `ServiceTokenWithScopes`, so the next `ServiceToken`, `ServiceTokenWithScopes` or service-account call exchanges a new one (after a 401 for a token revoked before its `exp`). No network call; thread-safe. | #### App (client) management — Admin port · admin JWT @@ -1324,7 +1348,7 @@ port (8081 in the default deployment). | `GetThreatMetrics` … `GetIPReputation` | `…/api/admin/security…` | JWT (admin) | Admin | | `GetDashboard*` / `*AdminLog*` | `…/api/admin/dashboard…`, `…/api/admin/logs…` | JWT (admin) | Admin | | `Decide` | `POST …/api/apps/{id}/service/policy/decide` | M2M | Admin | -| `ServiceToken` | `POST …/oauth/token` (`client_credentials`) | client secret | OAuth | +| `ServiceToken` / `ServiceTokenWithScopes` | `POST …/oauth/token` (`client_credentials`, with `scope` for the latter) | client secret | OAuth | ### Roles (highest → lowest privilege) @@ -1381,7 +1405,9 @@ your own authorization first. `jwtauth.RequireScopes` and the token lacks a listed scope (the `WWW-Authenticate` header names them). Add the scope to the calling client's `allowed_scopes` in Socrate (an application-defined `:` scope -needs Socrate v1.14.0 or later) and check the token's `scope` claim (§4). +needs Socrate v1.14.0 or later), have the caller request it +(`ServiceTokenWithScopes`; `ServiceToken` requests none and gets `api`) and +check the token's `scope` claim (§4). **The BFF answers 403 on a `POST`.** The request lacks the session's CSRF token. Read `csrf` from your session endpoint and send it as `X-CSRF-Token` on diff --git a/socrate/bff.go b/socrate/bff.go index aaff8b9..7545b2e 100644 --- a/socrate/bff.go +++ b/socrate/bff.go @@ -54,6 +54,23 @@ func (e *OAuthError) Error() string { return fmt.Sprintf("token endpoint HTTP %d: %s", e.StatusCode, e.Description) } +// newOAuthError builds the *OAuthError of a non-2xx /oauth/token answer with +// status and body b: the RFC 6749 §5.2 error and error_description when b is +// such a body, else b itself as the Description. +func newOAuthError(status int, b []byte) *OAuthError { + var body struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + oe := &OAuthError{StatusCode: status} + if json.Unmarshal(b, &body) == nil && body.Error != "" { + oe.Code, oe.Description = body.Error, body.ErrorDescription + } else { + oe.Description = string(b) + } + return oe +} + // TokenSet is the response of the /oauth/token endpoint (authorization_code and // refresh_token grants). RefreshToken and IDToken are present depending on the // granted scopes and flow. @@ -131,17 +148,7 @@ func decodeTokenSet(resp *http.Response) (*TokenSet, error) { return nil, fmt.Errorf("read token response: %w", err) } if resp.StatusCode != http.StatusOK { - var body struct { - Error string `json:"error"` - ErrorDescription string `json:"error_description"` - } - oe := &OAuthError{StatusCode: resp.StatusCode} - if json.Unmarshal(b, &body) == nil && body.Error != "" { - oe.Code, oe.Description = body.Error, body.ErrorDescription - } else { - oe.Description = string(b) - } - return nil, oe + return nil, newOAuthError(resp.StatusCode, b) } var ts TokenSet if err := json.Unmarshal(b, &ts); err != nil { diff --git a/socrate/client.go b/socrate/client.go index d084868..cf338c9 100644 --- a/socrate/client.go +++ b/socrate/client.go @@ -70,6 +70,12 @@ type Client struct { svcTokenMu sync.Mutex svcToken string svcTokenExpiry time.Time + + // Tokens of ServiceTokenWithScopes, keyed by normalised scope set; scopedMu + // guards the map and scopedTick (see service_token_scopes.go). + scopedMu sync.Mutex + scoped map[string]*scopedServiceToken + scopedTick uint64 } // ClientConfig holds the constructor options for Client. @@ -276,6 +282,11 @@ type tokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` + // Scope is the raw granted scope, empty when the response has no scope + // field (RFC 6749 §5.1: it may be omitted when identical to the requested + // one). Kept raw so that only ServiceTokenWithScopes, which checks it, + // depends on its type. + Scope json.RawMessage `json:"scope"` } // getServiceToken returns a cached service-account token, refreshing it when @@ -291,27 +302,52 @@ func (c *Client) getServiceToken(ctx context.Context) (string, error) { // another service that accepts Socrate tokens. It is the same token the client's service-account // calls use: cached, and exchanged again only when it is missing or expires within 30 s. // Concurrent callers share one exchange. It requires ClientSecret; the token is never logged or -// put in an error. +// put in an error. It requests no scope, so Socrate issues its default (api); use +// ServiceTokenWithScopes for a token carrying specific scopes. func (c *Client) ServiceToken(ctx context.Context) (token string, expiresAt time.Time, err error) { c.svcTokenMu.Lock() defer c.svcTokenMu.Unlock() - if c.svcToken != "" && time.Now().Add(30*time.Second).Before(c.svcTokenExpiry) { + if c.svcToken != "" && time.Now().Add(serviceTokenRenewBefore).Before(c.svcTokenExpiry) { return c.svcToken, c.svcTokenExpiry, nil } if c.clientSecret == "" { return "", time.Time{}, errors.New("socrate: client_secret required for service-account token exchange") } + tr, expiry, err := c.exchangeClientCredentials(ctx, "", func(status int, body []byte) error { + return fmt.Errorf("token exchange HTTP %d: %s", status, body) + }) + if err != nil { + return "", time.Time{}, err + } + c.svcToken = tr.AccessToken + c.svcTokenExpiry = expiry + return c.svcToken, c.svcTokenExpiry, nil +} + +// serviceTokenRenewBefore is how long before its expiry a cached +// service-account token is exchanged again. +const serviceTokenRenewBefore = 30 * time.Second + +// exchangeClientCredentials performs one client_credentials exchange at +// /oauth/token and returns the response and the token's expiry (tokenExpiry). +// scope is sent as the scope parameter when non-empty. A non-200 answer is +// turned into an error by httpErr, given the status and the body (an error +// body, never a token). The caller checks ClientSecret and owns the caching. +func (c *Client) exchangeClientCredentials(ctx context.Context, scope string, httpErr func(status int, body []byte) error) (tokenResponse, time.Time, error) { data := url.Values{ "grant_type": {"client_credentials"}, "client_id": {c.clientID}, "client_secret": {c.clientSecret}, } + if scope != "" { + data.Set("scope", scope) + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.oauthURL("/oauth/token"), bytes.NewBufferString(data.Encode())) if err != nil { - return "", time.Time{}, fmt.Errorf("build token request: %w", err) + return tokenResponse{}, time.Time{}, fmt.Errorf("build token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // Deliberately no ApplyClientAttribution: this is the application acting @@ -325,26 +361,24 @@ func (c *Client) ServiceToken(ctx context.Context) (token string, expiresAt time sent := time.Now() resp, err := c.httpClient.Do(req) if err != nil { - return "", time.Time{}, fmt.Errorf("token exchange: %w", err) + return tokenResponse{}, time.Time{}, fmt.Errorf("token exchange: %w", err) } b, err := readBody(resp) if err != nil { - return "", time.Time{}, fmt.Errorf("read token response: %w", err) + return tokenResponse{}, time.Time{}, fmt.Errorf("read token response: %w", err) } if resp.StatusCode != http.StatusOK { - return "", time.Time{}, fmt.Errorf("token exchange HTTP %d: %s", resp.StatusCode, b) + return tokenResponse{}, time.Time{}, httpErr(resp.StatusCode, b) } var tr tokenResponse if err := json.Unmarshal(b, &tr); err != nil { - return "", time.Time{}, fmt.Errorf("decode token response: %w", err) + return tokenResponse{}, time.Time{}, fmt.Errorf("decode token response: %w", err) } if tr.AccessToken == "" { - return "", time.Time{}, errors.New("token exchange: no access_token in the response") + return tokenResponse{}, time.Time{}, errors.New("token exchange: no access_token in the response") } - c.svcToken = tr.AccessToken - c.svcTokenExpiry = tokenExpiry(tr, sent) - return c.svcToken, c.svcTokenExpiry, nil + return tr, tokenExpiry(tr, sent), nil } // InvalidateServiceToken drops the cached service-account token, so the next @@ -355,11 +389,20 @@ func (c *Client) ServiceToken(ctx context.Context) (token string, expiresAt time // finishes and its token is cached as usual. Every call drops whatever is // cached, including a token another goroutine has just obtained, so callers // refused at the same time may cause a few extra exchanges (harmless). +// +// It also drops every token cached by ServiceTokenWithScopes, whatever its +// scopes: a revocation at Socrate seldom concerns a single token, and an +// extra exchange is cheap. A scoped exchange in progress finishes and its +// token is returned to the callers waiting for it, but not cached. func (c *Client) InvalidateServiceToken() { c.svcTokenMu.Lock() - defer c.svcTokenMu.Unlock() c.svcToken = "" c.svcTokenExpiry = time.Time{} + c.svcTokenMu.Unlock() + + c.scopedMu.Lock() + c.scoped = nil + c.scopedMu.Unlock() } // unknownTokenLifetime is how long a token whose response gives no expiry at all diff --git a/socrate/service_token_scopes.go b/socrate/service_token_scopes.go new file mode 100644 index 0000000..7411b95 --- /dev/null +++ b/socrate/service_token_scopes.go @@ -0,0 +1,173 @@ +package socrate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "sync" + "time" +) + +// ErrScopeNotGranted is returned (wrapped) by ServiceTokenWithScopes when +// Socrate does not grant every requested scope: either it refuses the request +// with invalid_scope (the error then also wraps the *OAuthError), or the +// token response's scope field lacks one of them. Test it with errors.Is. +var ErrScopeNotGranted = errors.New("socrate: scope not granted") + +// maxScopedServiceTokens bounds how many distinct scope sets +// ServiceTokenWithScopes caches; the least recently used set is evicted +// beyond it. +const maxScopedServiceTokens = 16 + +// scopedServiceToken is the cache entry of one scope set. mu is held during +// an exchange, so concurrent callers for the same set share it; lastUsed is +// guarded by Client.scopedMu. +type scopedServiceToken struct { + mu sync.Mutex + token string + expiry time.Time + lastUsed uint64 +} + +// ServiceTokenWithScopes returns a service-account access token (the +// client_credentials grant, sub=app:{id}) requested with the given scopes, and +// the time it really expires, as ServiceToken does. Use it to call a service +// that requires a scope of its own, for example a worker API behind +// jwtauth.RequireScopes("swingdrift:worker"): register the scope in the +// client's allowed_scopes in Socrate (application-defined : +// scopes need Socrate v1.14.0 or later), then request it here. Socrate issues +// exactly the requested scopes, without its default api, so list api too when +// the token must also carry it. +// +// The token is cached per scope set (order and duplicates do not matter: +// ("a", "b") and ("b", "a", "a") share one token) and exchanged again only when +// it is missing or expires within 30 s; concurrent callers for the same set +// share one exchange. At most 16 sets are cached; beyond that the least +// recently used one is dropped (and exchanged again when next asked for). +// This cache is separate from ServiceToken's, which keeps the scope-less token +// of the client's own service-account calls; InvalidateServiceToken clears +// both. +// +// It fails closed, without a network call, when no scope is given (use +// ServiceToken for the default scope), when a scope is not an RFC 6749 §3.3 +// scope-token (non-empty, printable ASCII without space, '"' or '\'), or when +// ClientSecret is not set. A refusal by Socrate is an *OAuthError (errors.As); +// invalid_scope (a scope the client does not register) also matches +// ErrScopeNotGranted, as does a response whose scope field lacks a requested +// scope (a response without a scope field means the requested scopes were +// granted, RFC 6749 §5.1). The token is never logged or put in an error. +func (c *Client) ServiceTokenWithScopes(ctx context.Context, scopes ...string) (token string, expiresAt time.Time, err error) { + set, err := normaliseScopes(scopes) + if err != nil { + return "", time.Time{}, err + } + if c.clientSecret == "" { + return "", time.Time{}, errors.New("socrate: client_secret required for service-account token exchange") + } + scope := strings.Join(set, " ") + + e := c.scopedEntry(scope) + e.mu.Lock() + defer e.mu.Unlock() + + if e.token != "" && time.Now().Add(serviceTokenRenewBefore).Before(e.expiry) { + return e.token, e.expiry, nil + } + + tr, expiry, err := c.exchangeClientCredentials(ctx, scope, func(status int, body []byte) error { + oe := newOAuthError(status, body) + if oe.Code == "invalid_scope" { + return fmt.Errorf("%w: %w", ErrScopeNotGranted, oe) + } + return oe + }) + if err != nil { + return "", time.Time{}, err + } + if len(tr.Scope) > 0 && string(tr.Scope) != "null" { + var grantedScope string + if err := json.Unmarshal(tr.Scope, &grantedScope); err != nil { + return "", time.Time{}, fmt.Errorf("%w: the token response's scope is not a string", ErrScopeNotGranted) + } + granted := strings.Fields(grantedScope) + var missing []string + for _, s := range set { + if !slices.Contains(granted, s) { + missing = append(missing, s) + } + } + if len(missing) > 0 { + return "", time.Time{}, fmt.Errorf("%w: the token response lacks %q (granted %q)", + ErrScopeNotGranted, strings.Join(missing, " "), grantedScope) + } + } + e.token, e.expiry = tr.AccessToken, expiry + return e.token, e.expiry, nil +} + +// scopedEntry returns the cache entry for the normalised scope set key, +// creating it (and evicting the least recently used entry beyond +// maxScopedServiceTokens) when needed, and marks it as used. +func (c *Client) scopedEntry(key string) *scopedServiceToken { + c.scopedMu.Lock() + defer c.scopedMu.Unlock() + + c.scopedTick++ + if e, ok := c.scoped[key]; ok { + e.lastUsed = c.scopedTick + return e + } + if c.scoped == nil { + c.scoped = make(map[string]*scopedServiceToken) + } + if len(c.scoped) >= maxScopedServiceTokens { + var oldest string + var oldestTick uint64 + for k, e := range c.scoped { + if oldest == "" || e.lastUsed < oldestTick { + oldest, oldestTick = k, e.lastUsed + } + } + // A caller still holding the evicted entry finishes with it; its + // token is simply no longer cached. + delete(c.scoped, oldest) + } + e := &scopedServiceToken{lastUsed: c.scopedTick} + c.scoped[key] = e + return e +} + +// normaliseScopes validates scopes as RFC 6749 §3.3 scope-tokens and returns +// them deduplicated and sorted. It refuses an empty list. +func normaliseScopes(scopes []string) ([]string, error) { + if len(scopes) == 0 { + return nil, errors.New("socrate: ServiceTokenWithScopes needs at least one scope (use ServiceToken for the default scope)") + } + set := make([]string, 0, len(scopes)) + for _, s := range scopes { + if !isScopeToken(s) { + return nil, fmt.Errorf("socrate: invalid scope %q: not an RFC 6749 scope-token", s) + } + set = append(set, s) + } + slices.Sort(set) + return slices.Compact(set), nil +} + +// isScopeToken reports whether s is a non-empty RFC 6749 §3.3 scope-token: +// 1*( %x21 / %x23-5B / %x5D-7E ). +func isScopeToken(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + ch := s[i] + if ch < 0x21 || ch > 0x7e || ch == '"' || ch == '\\' { + return false + } + } + return true +} diff --git a/socrate/service_token_scopes_test.go b/socrate/service_token_scopes_test.go new file mode 100644 index 0000000..944e8e2 --- /dev/null +++ b/socrate/service_token_scopes_test.go @@ -0,0 +1,449 @@ +package socrate_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ovander/backendkit/ctxutil" + "github.com/ovander/backendkit/socrate" +) + +// scopedTokenServer fakes Socrate's /oauth/token for client_credentials. Each +// exchange issues "tok-"; respond, when set, writes the answer instead +// (given the requested scope and the token it would issue). It records the +// scope parameter of every request, and whether it was present. +type scopedTokenServer struct { + mu sync.Mutex + scopes []string // scope parameter per request; "" when absent + corrIDs []string + n atomic.Int32 + expiresIn int + respond func(w http.ResponseWriter, scope, tok string) +} + +func (s *scopedTokenServer) requests() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.scopes...) +} + +func newScopedTokenServer(t *testing.T, s *scopedTokenServer) *socrate.Client { + t.Helper() + if s.expiresIn == 0 { + s.expiresIn = 3600 + } + mux := http.NewServeMux() + mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil || r.PostForm.Get("grant_type") != "client_credentials" || + r.PostForm.Get("client_id") != "cid" || r.PostForm.Get("client_secret") != "s3cret" { + w.WriteHeader(http.StatusBadRequest) + return + } + scope := "" + if vs, ok := r.PostForm["scope"]; ok { + scope = strings.Join(vs, ",") + } + s.mu.Lock() + s.scopes = append(s.scopes, scope) + s.corrIDs = append(s.corrIDs, r.Header.Get("X-Correlation-ID")) + s.mu.Unlock() + tok := fmt.Sprintf("tok-%d", s.n.Add(1)) + if s.respond != nil { + s.respond(w, scope, tok) + return + } + body := map[string]any{"access_token": tok, "expires_in": s.expiresIn, "token_type": "Bearer"} + if scope != "" { + body["scope"] = scope + } else { + body["scope"] = "api" + } + _ = json.NewEncoder(w).Encode(body) + }) + srv, closeFn := newTestServer(mux) + t.Cleanup(closeFn) + c, err := socrate.NewClient(socrate.ClientConfig{BaseURL: srv.URL, AdminBaseURL: srv.URL, ClientID: "cid", ClientSecret: "s3cret"}) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestServiceTokenWithScopes_SendsNormalisedScopeAndCaches(t *testing.T) { + s := &scopedTokenServer{} + c := newScopedTokenServer(t, s) + ctx := context.Background() + + before := time.Now() + tok, exp, err := c.ServiceTokenWithScopes(ctx, "swingdrift:worker", "api", "swingdrift:worker") + if err != nil || tok != "tok-1" { + t.Fatalf("ServiceTokenWithScopes = %q, %v", tok, err) + } + if exp.Before(before.Add(59*time.Minute)) || exp.After(time.Now().Add(time.Hour)) { + t.Errorf("expiry %v, want about one hour from now", exp) + } + if got := s.requests(); len(got) != 1 || got[0] != "api swingdrift:worker" { + t.Fatalf("scope parameters %q, want one request with \"api swingdrift:worker\"", got) + } + + // Same set in another order: the cached token, no exchange. + tok2, exp2, err := c.ServiceTokenWithScopes(ctx, "api", "swingdrift:worker") + if err != nil || tok2 != tok || !exp2.Equal(exp) || s.n.Load() != 1 { + t.Errorf("same set reordered: %q %v %v after %d exchanges; want the cached token", tok2, exp2, err, s.n.Load()) + } + + // A different set gets its own token. + tok3, _, err := c.ServiceTokenWithScopes(ctx, "swingdrift:worker") + if err != nil || tok3 != "tok-2" { + t.Errorf("different set: %q, %v; want tok-2", tok3, err) + } + if again, _, _ := c.ServiceTokenWithScopes(ctx, "swingdrift:worker"); again != tok3 || s.n.Load() != 2 { + t.Errorf("different set is cached too: %q after %d exchanges", again, s.n.Load()) + } + if got := s.requests(); len(got) != 2 || got[1] != "swingdrift:worker" { + t.Errorf("scope parameters %q", got) + } +} + +func TestServiceTokenWithScopes_SeparateFromServiceToken(t *testing.T) { + s := &scopedTokenServer{} + c := newScopedTokenServer(t, s) + ctx := context.Background() + + scoped, _, err := c.ServiceTokenWithScopes(ctx, "swingdrift:worker") + if err != nil || scoped != "tok-1" { + t.Fatalf("ServiceTokenWithScopes = %q, %v", scoped, err) + } + plain, _, err := c.ServiceToken(ctx) + if err != nil || plain != "tok-2" { + t.Fatalf("ServiceToken = %q, %v; want its own token from a second exchange", plain, err) + } + if got := s.requests(); len(got) != 2 || got[1] != "" { + t.Errorf("ServiceToken sent scope %q, want no scope parameter", got) + } + // Each stays cached on its own. + if again, _, _ := c.ServiceToken(ctx); again != plain { + t.Errorf("ServiceToken = %q, want the cached %q", again, plain) + } + if again, _, _ := c.ServiceTokenWithScopes(ctx, "swingdrift:worker"); again != scoped { + t.Errorf("ServiceTokenWithScopes = %q, want the cached %q", again, scoped) + } + if s.n.Load() != 2 { + t.Errorf("%d exchanges, want 2", s.n.Load()) + } +} + +func TestServiceTokenWithScopes_RenewsNearExpiry(t *testing.T) { + // A token valid less than 30 s is exchanged again on every call. + s := &scopedTokenServer{expiresIn: 10} + c := newScopedTokenServer(t, s) + for i := range 2 { + tok, _, err := c.ServiceTokenWithScopes(context.Background(), "a:b") + if err != nil || tok != fmt.Sprintf("tok-%d", i+1) { + t.Fatalf("call %d: %q, %v", i, tok, err) + } + } + if s.n.Load() != 2 { + t.Errorf("%d exchanges, want 2", s.n.Load()) + } +} + +func TestServiceTokenWithScopes_ConcurrentCallersShareOneExchange(t *testing.T) { + s := &scopedTokenServer{respond: func(w http.ResponseWriter, scope, tok string) { + time.Sleep(50 * time.Millisecond) + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": tok, "expires_in": 3600, "scope": scope}) + }} + c := newScopedTokenServer(t, s) + var wg sync.WaitGroup + for i := range 60 { + wg.Add(1) + go func() { + defer wg.Done() + scopes := []string{"a:x", "b:y"} + if i%2 == 1 { + scopes = []string{"b:y", "a:x"} + } + if i%3 == 0 { + scopes = []string{"c:z"} + } + if tok, _, err := c.ServiceTokenWithScopes(context.Background(), scopes...); err != nil || tok == "" { + t.Errorf("ServiceTokenWithScopes = %q, %v", tok, err) + } + }() + } + wg.Wait() + if s.n.Load() != 2 { + t.Errorf("%d exchanges for two scope sets, want 2", s.n.Load()) + } +} + +func TestServiceTokenWithScopes_RejectsInvalidScopesWithoutARequest(t *testing.T) { + s := &scopedTokenServer{} + c := newScopedTokenServer(t, s) + cases := map[string][]string{ + "no scope": nil, + "empty scope": {""}, + "one empty": {"a:b", ""}, + "space": {"a:b c:d"}, + "leading space": {" a:b"}, + "double quote": {`a"b`}, + "backslash": {`a\b`}, + "control": {"a\tb"}, + "non-ASCII": {"a:é"}, + "DEL": {"a\x7f"}, + "newline": {"a\nb"}, + "valid then soft": {"a:b", "x y"}, + } + for name, scopes := range cases { + t.Run(name, func(t *testing.T) { + tok, exp, err := c.ServiceTokenWithScopes(context.Background(), scopes...) + if err == nil || tok != "" || !exp.IsZero() { + t.Errorf("ServiceTokenWithScopes(%q) = %q %v %v, want an error", scopes, tok, exp, err) + } + }) + } + if s.n.Load() != 0 { + t.Errorf("%d requests made, want none", s.n.Load()) + } + + noSecret, err := socrate.NewClient(socrate.ClientConfig{BaseURL: "http://127.0.0.1:1", ClientID: "cid"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := noSecret.ServiceTokenWithScopes(context.Background(), "a:b"); err == nil || !strings.Contains(err.Error(), "client_secret") { + t.Errorf("no secret: %v", err) + } +} + +func TestServiceTokenWithScopes_InvalidScopeIsRecognisable(t *testing.T) { + s := &scopedTokenServer{respond: func(w http.ResponseWriter, _, _ string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_scope","error_description":"requested scope is not allowed for this client"}`)) + }} + c := newScopedTokenServer(t, s) + tok, exp, err := c.ServiceTokenWithScopes(context.Background(), "swingdrift:worker") + if err == nil || tok != "" || !exp.IsZero() { + t.Fatalf("invalid_scope: %q %v %v, want an error", tok, exp, err) + } + if !errors.Is(err, socrate.ErrScopeNotGranted) { + t.Errorf("errors.Is(%v, ErrScopeNotGranted) = false", err) + } + var oe *socrate.OAuthError + if !errors.As(err, &oe) || oe.Code != "invalid_scope" || oe.StatusCode != http.StatusBadRequest { + t.Errorf("errors.As OAuthError: %+v from %v", oe, err) + } + if strings.Contains(err.Error(), "s3cret") { + t.Errorf("error leaks the secret: %v", err) + } + // Nothing is cached: the next call asks again. + _, _, _ = c.ServiceTokenWithScopes(context.Background(), "swingdrift:worker") + if s.n.Load() != 2 { + t.Errorf("%d exchanges, want 2 (a failure is not cached)", s.n.Load()) + } +} + +func TestServiceTokenWithScopes_OtherRefusalIsAnOAuthError(t *testing.T) { + s := &scopedTokenServer{respond: func(w http.ResponseWriter, _, _ string) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client"}`)) + }} + c := newScopedTokenServer(t, s) + _, _, err := c.ServiceTokenWithScopes(context.Background(), "a:b") + var oe *socrate.OAuthError + if !errors.As(err, &oe) || oe.Code != "invalid_client" || oe.StatusCode != http.StatusUnauthorized { + t.Errorf("errors.As OAuthError: %+v from %v", oe, err) + } + if errors.Is(err, socrate.ErrScopeNotGranted) { + t.Errorf("invalid_client must not match ErrScopeNotGranted: %v", err) + } +} + +func TestServiceTokenWithScopes_ResponseScope(t *testing.T) { + const secretTok = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhcHA6MSJ9.super-secret-signature" + tests := []struct { + name string + body map[string]any + wantErr bool + }{ + {"scope missing a requested one", map[string]any{"scope": "api a:b"}, true}, + {"empty scope", map[string]any{"scope": ""}, true}, + {"non-string scope", map[string]any{"scope": []string{"a:b", "c:d"}}, true}, + {"null scope", map[string]any{"scope": nil}, false}, + {"no scope field", map[string]any{}, false}, + {"exact scope", map[string]any{"scope": "c:d a:b"}, false}, + {"wider scope", map[string]any{"scope": "a:b api c:d"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := &scopedTokenServer{respond: func(w http.ResponseWriter, _, _ string) { + body := map[string]any{"access_token": secretTok, "expires_in": 3600, "token_type": "Bearer"} + for k, v := range tt.body { + body[k] = v + } + _ = json.NewEncoder(w).Encode(body) + }} + c := newScopedTokenServer(t, s) + tok, exp, err := c.ServiceTokenWithScopes(context.Background(), "a:b", "c:d") + if !tt.wantErr { + if err != nil || tok != secretTok { + t.Fatalf("ServiceTokenWithScopes = %q, %v; want the token", tok, err) + } + return + } + if err == nil || tok != "" || !exp.IsZero() { + t.Fatalf("ServiceTokenWithScopes = %q %v %v, want an error", tok, exp, err) + } + if !errors.Is(err, socrate.ErrScopeNotGranted) { + t.Errorf("errors.Is(%v, ErrScopeNotGranted) = false", err) + } + if _, isString := tt.body["scope"].(string); isString && !strings.Contains(err.Error(), "c:d") { + t.Errorf("error %q does not name the missing scope", err) + } + if strings.Contains(err.Error(), "super-secret") || strings.Contains(err.Error(), "eyJ") { + t.Errorf("error leaks the token: %v", err) + } + // Not cached: the next call exchanges again. + _, _, _ = c.ServiceTokenWithScopes(context.Background(), "a:b", "c:d") + if s.n.Load() != 2 { + t.Errorf("%d exchanges, want 2", s.n.Load()) + } + }) + } +} + +func TestServiceTokenWithScopes_NoTokenInErrors(t *testing.T) { + const secretTok = "tok-must-not-leak" + for name, respond := range map[string]func(w http.ResponseWriter, scope, tok string){ + "bad json": func(w http.ResponseWriter, _, _ string) { + _, _ = w.Write([]byte(`{"access_token":"` + secretTok + `","expires_in":"soon"}`)) + }, + "scope not granted": func(w http.ResponseWriter, _, _ string) { + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": secretTok, "scope": "api"}) + }, + "no access_token": func(w http.ResponseWriter, _, _ string) { + _ = json.NewEncoder(w).Encode(map[string]any{"scope": "a:b"}) + }, + } { + t.Run(name, func(t *testing.T) { + c := newScopedTokenServer(t, &scopedTokenServer{respond: respond}) + _, _, err := c.ServiceTokenWithScopes(context.Background(), "a:b") + if err == nil { + t.Fatal("want an error") + } + if strings.Contains(err.Error(), secretTok) || strings.Contains(err.Error(), "s3cret") { + t.Errorf("error leaks a credential: %v", err) + } + }) + } +} + +func TestServiceTokenWithScopes_CacheIsBoundedLRU(t *testing.T) { + s := &scopedTokenServer{} + c := newScopedTokenServer(t, s) + ctx := context.Background() + get := func(i int) string { + t.Helper() + tok, _, err := c.ServiceTokenWithScopes(ctx, fmt.Sprintf("set:s%d", i)) + if err != nil { + t.Fatal(err) + } + return tok + } + + first := make([]string, 17) + for i := range 16 { + first[i] = get(i) + } + if s.n.Load() != 16 { + t.Fatalf("%d exchanges, want 16", s.n.Load()) + } + // Touch s0 so s1 becomes the least recently used, then add a 17th set. + if get(0) != first[0] { + t.Fatal("s0 not cached") + } + first[16] = get(16) + if s.n.Load() != 17 { + t.Fatalf("%d exchanges, want 17", s.n.Load()) + } + // s0 and s2..s16 are still cached; s1 was evicted. + for _, i := range []int{0, 2, 15, 16} { + if got := get(i); got != first[i] { + t.Errorf("s%d: %q, want the cached %q", i, got, first[i]) + } + } + if s.n.Load() != 17 { + t.Fatalf("%d exchanges after cache hits, want 17", s.n.Load()) + } + if got := get(1); got == first[1] || s.n.Load() != 18 { + t.Errorf("s1 after eviction: %q after %d exchanges; want a new token from an 18th exchange", got, s.n.Load()) + } +} + +func TestServiceTokenWithScopes_InvalidateServiceTokenClearsScopedCaches(t *testing.T) { + s := &scopedTokenServer{} + c := newScopedTokenServer(t, s) + ctx := context.Background() + + a, _, _ := c.ServiceTokenWithScopes(ctx, "a:b") + plain, _, _ := c.ServiceToken(ctx) + if a != "tok-1" || plain != "tok-2" { + t.Fatalf("tokens %q %q", a, plain) + } + c.InvalidateServiceToken() + a2, _, err := c.ServiceTokenWithScopes(ctx, "a:b") + if err != nil || a2 == a { + t.Errorf("after invalidation the scoped token is exchanged again: %q, %v", a2, err) + } + plain2, _, err := c.ServiceToken(ctx) + if err != nil || plain2 == plain { + t.Errorf("after invalidation ServiceToken exchanges again: %q, %v", plain2, err) + } + if s.n.Load() != 4 { + t.Errorf("%d exchanges, want 4", s.n.Load()) + } + if again, _, _ := c.ServiceTokenWithScopes(ctx, "a:b"); again != a2 || s.n.Load() != 4 { + t.Errorf("the new scoped token is cached: %q after %d exchanges", again, s.n.Load()) + } +} + +func TestServiceTokenWithScopes_InvalidateConcurrent(t *testing.T) { + c := newScopedTokenServer(t, &scopedTokenServer{}) + var wg sync.WaitGroup + for i := range 40 { + wg.Add(1) + go func() { + defer wg.Done() + if i%4 == 0 { + c.InvalidateServiceToken() + return + } + if tok, _, err := c.ServiceTokenWithScopes(context.Background(), fmt.Sprintf("set:s%d", i%3)); err != nil || tok == "" { + t.Errorf("ServiceTokenWithScopes = %q, %v", tok, err) + } + }() + } + wg.Wait() +} + +func TestServiceTokenWithScopes_ForwardsCorrelationID(t *testing.T) { + s := &scopedTokenServer{} + c := newScopedTokenServer(t, s) + ctx := ctxutil.WithRequestID(context.Background(), "req-123") + if _, _, err := c.ServiceTokenWithScopes(ctx, "a:b"); err != nil { + t.Fatal(err) + } + s.mu.Lock() + defer s.mu.Unlock() + if len(s.corrIDs) != 1 || s.corrIDs[0] != "req-123" { + t.Errorf("X-Correlation-ID %q, want req-123", s.corrIDs) + } +} diff --git a/socrate/service_token_test.go b/socrate/service_token_test.go index 05b008c..847227a 100644 --- a/socrate/service_token_test.go +++ b/socrate/service_token_test.go @@ -164,3 +164,18 @@ func TestServiceToken_ExpiresInCountedFromTheRequest(t *testing.T) { t.Errorf("expiry %v is later than request start + expires_in (%v) (%v)", got, limit, err) } } + +// ServiceToken ignores the response's scope field, whatever its type. +func TestServiceToken_IgnoresResponseScope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "svc-tok", "expires_in": 3600, "scope": 42}) + })) + t.Cleanup(srv.Close) + c, err := socrate.NewClient(socrate.ClientConfig{BaseURL: srv.URL, ClientID: "cid", ClientSecret: "test-secret"}) + if err != nil { + t.Fatal(err) + } + if tok, _, err := c.ServiceToken(context.Background()); err != nil || tok != "svc-tok" { + t.Errorf("ServiceToken = %q, %v", tok, err) + } +}