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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### Changed
- Docs: the README, `docs/CLIENT-INTEGRATION.md`, `docs/MIGRATING-TO-SOCRATE.md` and
`CONTRIBUTING.md` follow the current code and Socrate v1.14: what a service account's
Expand Down
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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` (`<namespace>:<name>`) 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:<id>`, 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.
Expand Down Expand Up @@ -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 `<namespace>:<name>` 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 `<namespace>:<name>` 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. |
Expand Down
36 changes: 31 additions & 5 deletions docs/CLIENT-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<namespace>:<name>`)
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
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 `<namespace>:<name>` 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
Expand Down
29 changes: 18 additions & 11 deletions socrate/bff.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading