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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ All notable changes to backendkit are documented here. Format:

## [Unreleased]

### Added
- `(*socrate.Client).ServiceToken(ctx) (token string, expiresAt time.Time, err error)`: the
application's `client_credentials` access token and its real expiry (the token's `exp` claim,
else `expires_in` counted from when the request was sent; not the cache's early renewal time),
for calling another service with
the app's own identity. It is the cached token of the service-account calls (exchanged again
within 30 s of expiry, one exchange for concurrent callers). Requested by Lakebridge (its consumer
client takes Socrate service tokens without hand-rolling the OAuth exchange).

### Changed
- A `client_credentials` response without an `access_token` is now an error instead of an empty
cached token (fail closed); a working caller never received one.
- The service-account token's expiry comes from its `exp` claim, else from `expires_in` counted
from when the request was sent (it was counted from the response, a little late); a response
with neither is trusted for one minute instead of a fixed 55 minutes.

### Documentation
- `jwtauth.WithAudiences` in the client integration guide (§5) and the migration guide (§3.1),
including which application `role` belongs to when several audiences are accepted, and the
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,8 @@ decision point) and will not work against Keycloak, Auth0 or other providers.

The client uses a dual-auth strategy: user-scoped calls forward the caller's JWT; service-account
calls acquire a `client_credentials` token automatically and cache it until near-expiry.
`ServiceToken(ctx)` returns that token and its expiry, for calling another service that accepts
Socrate tokens as the application itself.

> **`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 Down
5 changes: 4 additions & 1 deletion docs/CLIENT-INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,8 @@ admin/superadmin; a regular user's JWT gets a 403.
**B. Service-account (M2M)** — the method exchanges your
`ClientID` + `ClientSecret` for a `client_credentials` token (cached until
near-expiry) and calls Socrate as the *app itself*, no human involved. Used for
backend-initiated actions: onboarding, magic links, background sync.
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.

```go
inv, err := client.InviteUserAsService(ctx, socrate.ServiceInviteRequest{
Expand Down Expand Up @@ -470,6 +471,7 @@ automatically from `client_id` (cached).
| Method | Auth | Returns | Notes |
|--------|------|---------|-------|
| `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`. |

#### App (client) management — Admin port · admin JWT

Expand Down Expand Up @@ -1273,6 +1275,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 |

### Roles (highest → lowest privilege)

Expand Down
66 changes: 55 additions & 11 deletions socrate/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ package socrate
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -278,14 +280,26 @@ type tokenResponse struct {
// getServiceToken returns a cached service-account token, refreshing it when
// near-expiry. Thread-safe.
func (c *Client) getServiceToken(ctx context.Context) (string, error) {
tok, _, err := c.ServiceToken(ctx)
return tok, err
}

// ServiceToken returns the application's service-account access token (the client_credentials
// grant, sub=app:{id}) and the time it really expires (the token's exp claim, else expires_in
// counted from when the request was sent; not the earlier time the cache renews it), for calling
// 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.
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) {
return c.svcToken, nil
return c.svcToken, c.svcTokenExpiry, nil
}
if c.clientSecret == "" {
return "", errors.New("socrate: client_secret required for service-account token exchange")
return "", time.Time{}, errors.New("socrate: client_secret required for service-account token exchange")
}

data := url.Values{
Expand All @@ -296,36 +310,66 @@ func (c *Client) getServiceToken(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.oauthURL("/oauth/token"), bytes.NewBufferString(data.Encode()))
if err != nil {
return "", fmt.Errorf("build token request: %w", err)
return "", 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
// as itself (no browser involved), and the token it yields is cached and
// shared across every later caller.

// Count expires_in from before the request: the token was issued at the
// latest when the response left Socrate, so this never overstates its life.
sent := time.Now()
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("token exchange: %w", err)
return "", time.Time{}, fmt.Errorf("token exchange: %w", err)
}
b, err := readBody(resp)
if err != nil {
return "", fmt.Errorf("read token response: %w", err)
return "", time.Time{}, fmt.Errorf("read token response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token exchange HTTP %d: %s", resp.StatusCode, b)
return "", time.Time{}, fmt.Errorf("token exchange HTTP %d: %s", resp.StatusCode, b)
}

var tr tokenResponse
if err := json.Unmarshal(b, &tr); err != nil {
return "", fmt.Errorf("decode token response: %w", err)
return "", 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")
}
c.svcToken = tr.AccessToken
c.svcTokenExpiry = tokenExpiry(tr, sent)
return c.svcToken, c.svcTokenExpiry, nil
}

// unknownTokenLifetime is how long a token whose response gives no expiry at all
// (neither an exp claim nor expires_in) is trusted: short, so that a guess can
// never outlive the real token by much. Socrate always sends both.
const unknownTokenLifetime = time.Minute

// tokenExpiry is when a token from the token endpoint expires. The access token's
// own exp claim comes first: it is the instant every verifier checks (read, not
// verified here: it only times the cache and the value ServiceToken returns).
// Without one, expires_in counted from sent, the moment the request was sent, so
// the result never lies past the real expiry. Without either, sent plus
// unknownTokenLifetime.
func tokenExpiry(tr tokenResponse, sent time.Time) time.Time {
if parts := strings.Split(tr.AccessToken, "."); len(parts) == 3 {
if payload, err := base64.RawURLEncoding.DecodeString(parts[1]); err == nil {
var claims struct {
Exp int64 `json:"exp"`
}
if json.Unmarshal(payload, &claims) == nil && claims.Exp > sent.Unix() {
return time.Unix(claims.Exp, 0)
}
}
}
if tr.ExpiresIn > 0 {
c.svcTokenExpiry = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
} else {
c.svcTokenExpiry = time.Now().Add(55 * time.Minute)
return sent.Add(time.Duration(tr.ExpiresIn) * time.Second)
}
return c.svcToken, nil
return sent.Add(unknownTokenLifetime)
}

// ────────────────────────────────────────────────────────────────────────────
Expand Down
166 changes: 166 additions & 0 deletions socrate/service_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package socrate_test

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"

"github.com/ovander/backendkit/socrate"
)

// tokenServer answers /oauth/token with tok and expiresIn, counting exchanges.
func tokenServer(t *testing.T, tok string, expiresIn int, status int) (*socrate.Client, *atomic.Int32) {
t.Helper()
var n atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
n.Add(1)
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
}
if status != http.StatusOK {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"error":"invalid_client"}`))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": tok, "expires_in": expiresIn, "token_type": "Bearer"})
})
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, &n
}

func TestServiceToken_ReturnsTokenAndExpiry(t *testing.T) {
c, n := tokenServer(t, "svc-tok", 3600, http.StatusOK)
before := time.Now()
tok, exp, err := c.ServiceToken(context.Background())
if err != nil || tok != "svc-tok" {
t.Fatalf("ServiceToken = %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)
}
// Cached: a second call makes no exchange and returns the same expiry.
tok2, exp2, err := c.ServiceToken(context.Background())
if err != nil || tok2 != tok || !exp2.Equal(exp) || n.Load() != 1 {
t.Errorf("second call: %q %v %v, %d exchanges", tok2, exp2, err, n.Load())
}
}

func TestServiceToken_ConcurrentCallersShareOneExchange(t *testing.T) {
c, n := tokenServer(t, "svc-tok", 3600, http.StatusOK)
var wg sync.WaitGroup
for range 50 {
wg.Add(1)
go func() {
defer wg.Done()
if tok, _, err := c.ServiceToken(context.Background()); err != nil || tok != "svc-tok" {
t.Errorf("ServiceToken = %q, %v", tok, err)
}
}()
}
wg.Wait()
if n.Load() != 1 {
t.Errorf("%d exchanges, want 1", n.Load())
}
}

func TestServiceToken_RefreshesNearExpiry(t *testing.T) {
// A token valid less than 30 s is exchanged again on every call.
c, n := tokenServer(t, "short", 10, http.StatusOK)
for range 2 {
if _, _, err := c.ServiceToken(context.Background()); err != nil {
t.Fatal(err)
}
}
if n.Load() != 2 {
t.Errorf("%d exchanges, want 2", n.Load())
}
}

func TestServiceToken_Errors(t *testing.T) {
c, _ := tokenServer(t, "svc-tok", 3600, http.StatusUnauthorized)
tok, exp, err := c.ServiceToken(context.Background())
if err == nil || tok != "" || !exp.IsZero() || strings.Contains(err.Error(), "s3cret") {
t.Errorf("rejected exchange: %q %v %v", tok, exp, err)
}

c, _ = tokenServer(t, "", 3600, http.StatusOK)
if tok, _, err := c.ServiceToken(context.Background()); err == nil || tok != "" {
t.Errorf("empty access_token accepted: %q %v", tok, err)
}

noSecret, err := socrate.NewClient(socrate.ClientConfig{BaseURL: "http://127.0.0.1:1", ClientID: "cid"})
if err != nil {
t.Fatal(err)
}
if _, _, err := noSecret.ServiceToken(context.Background()); err == nil || !strings.Contains(err.Error(), "client_secret") {
t.Errorf("no secret: %v", err)
}

ctx, cancel := context.WithCancel(context.Background())
cancel()
c, _ = tokenServer(t, "svc-tok", 3600, http.StatusOK)
if _, _, err := c.ServiceToken(ctx); err == nil {
t.Error("cancelled context: no error")
}
}

func TestServiceToken_ExpiryFromTheTokenWithoutExpiresIn(t *testing.T) {
exp := time.Now().Add(5 * time.Minute).Truncate(time.Second)
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"sub":"app:x","exp":%d}`, exp.Unix())))
c, _ := tokenServer(t, "eyJhbGciOiJSUzI1NiJ9."+payload+".sig", 0, http.StatusOK)
_, got, err := c.ServiceToken(context.Background())
if err != nil || !got.Equal(exp) {
t.Errorf("expiry %v, want the token's exp %v (%v)", got, exp, err)
}
// Neither expires_in nor a readable exp: a short lifetime, never a long guess.
c, _ = tokenServer(t, "opaque", 0, http.StatusOK)
if _, got, err := c.ServiceToken(context.Background()); err != nil || got.After(time.Now().Add(time.Minute)) {
t.Errorf("opaque token expiry %v (%v), want at most a minute ahead", got, err)
}
}

// The token's exp is the instant verifiers check: it wins over expires_in.
func TestServiceToken_ExpPreferredOverExpiresIn(t *testing.T) {
exp := time.Now().Add(5 * time.Minute).Truncate(time.Second)
payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"sub":"app:x","exp":%d}`, exp.Unix())))
c, _ := tokenServer(t, "eyJhbGciOiJSUzI1NiJ9."+payload+".sig", 3600, http.StatusOK)
if _, got, err := c.ServiceToken(context.Background()); err != nil || !got.Equal(exp) {
t.Errorf("expiry %v, want the token's exp %v (%v)", got, exp, err)
}
}

// expires_in is counted from before the request, so a slow exchange cannot make
// the returned expiry later than the real one.
func TestServiceToken_ExpiresInCountedFromTheRequest(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(300 * time.Millisecond)
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "opaque", "expires_in": 60, "token_type": "Bearer"})
}))
t.Cleanup(srv.Close)
c, err := socrate.NewClient(socrate.ClientConfig{BaseURL: srv.URL, ClientID: "cid", ClientSecret: "test-secret"})
if err != nil {
t.Fatal(err)
}
// The server takes 300 ms; counting from the response would land 300 ms late.
limit := time.Now().Add(60*time.Second + 100*time.Millisecond)
_, got, err := c.ServiceToken(context.Background())
if err != nil || got.After(limit) {
t.Errorf("expiry %v is later than request start + expires_in (%v) (%v)", got, limit, err)
}
}
Loading