From 243fbf1d36f764080e19c45d025af4a5d405a7da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:28:05 +0000 Subject: [PATCH 1/3] feat(socrate): export ServiceToken for calling other services as the app ServiceToken(ctx) returns the cached client_credentials access token and its expiry; the unexported getServiceToken now wraps it. A token response without access_token is an error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014dfgURbaXrGaV6cdxw8JBt --- CHANGELOG.md | 11 ++++ README.md | 2 + docs/CLIENT-INTEGRATION.md | 5 +- socrate/client.go | 29 ++++++--- socrate/service_token_test.go | 118 ++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 9 deletions(-) create mode 100644 socrate/service_token_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a73c71..9721792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ 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 expiry, 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. + ### 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 diff --git a/README.md b/README.md index bc64c5a..42df208 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index 68e51e1..626fe9a 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -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{ @@ -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 expiry, 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 @@ -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) diff --git a/socrate/client.go b/socrate/client.go index d8e9c9f..d60ac8d 100644 --- a/socrate/client.go +++ b/socrate/client.go @@ -278,14 +278,24 @@ 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 expires, 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{ @@ -296,7 +306,7 @@ 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 @@ -305,19 +315,22 @@ func (c *Client) getServiceToken(ctx context.Context) (string, error) { 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 if tr.ExpiresIn > 0 { @@ -325,7 +338,7 @@ func (c *Client) getServiceToken(ctx context.Context) (string, error) { } else { c.svcTokenExpiry = time.Now().Add(55 * time.Minute) } - return c.svcToken, nil + return c.svcToken, c.svcTokenExpiry, nil } // ──────────────────────────────────────────────────────────────────────────── diff --git a/socrate/service_token_test.go b/socrate/service_token_test.go new file mode 100644 index 0000000..f42976b --- /dev/null +++ b/socrate/service_token_test.go @@ -0,0 +1,118 @@ +package socrate_test + +import ( + "context" + "encoding/json" + "net/http" + "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") + } +} From 19362f445f2cf0fd240377cf26e5b2fac19d8acc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:32:56 +0000 Subject: [PATCH 2/3] fix(socrate): ServiceToken expiry from the token's exp when expires_in is absent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014dfgURbaXrGaV6cdxw8JBt --- CHANGELOG.md | 5 ++++- docs/CLIENT-INTEGRATION.md | 2 +- socrate/client.go | 31 +++++++++++++++++++++++++------ socrate/service_token_test.go | 17 +++++++++++++++++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9721792..015b6d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ All notable changes to backendkit are documented here. Format: ### Added - `(*socrate.Client).ServiceToken(ctx) (token string, expiresAt time.Time, err error)`: the - application's `client_credentials` access token and its expiry, for calling another service with + application's `client_credentials` access token and its real expiry (`expires_in`, else the + token's `exp` claim; 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). @@ -16,6 +17,8 @@ All notable changes to backendkit are documented here. Format: ### 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. +- Without `expires_in`, the service-account token is cached until its `exp` claim instead of a + fixed 55 minutes (still the fallback for a token without a readable `exp`). ### Documentation - `jwtauth.WithAudiences` in the client integration guide (§5) and the migration guide (§3.1), diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index 626fe9a..02971ef 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -471,7 +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 expiry, 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`. | +| `ServiceToken(ctx)` | M2M | `string, time.Time` | the app's own `client_credentials` access token and its real expiry (`expires_in`, else the token's `exp`), 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 diff --git a/socrate/client.go b/socrate/client.go index d60ac8d..a9574f8 100644 --- a/socrate/client.go +++ b/socrate/client.go @@ -29,12 +29,14 @@ package socrate import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" + "strings" "sync" "time" @@ -283,8 +285,9 @@ func (c *Client) getServiceToken(ctx context.Context) (string, error) { } // ServiceToken returns the application's service-account access token (the client_credentials -// grant, sub=app:{id}) and the time it expires, for calling another service that accepts Socrate -// tokens. It is the same token the client's service-account calls use: cached, and exchanged again +// grant, sub=app:{id}) and the time it really expires (from expires_in, else the token's exp +// claim; 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) { @@ -333,12 +336,28 @@ func (c *Client) ServiceToken(ctx context.Context) (token string, expiresAt time return "", time.Time{}, errors.New("token exchange: no access_token in the response") } c.svcToken = tr.AccessToken + c.svcTokenExpiry = tokenExpiry(tr, time.Now()) + return c.svcToken, c.svcTokenExpiry, nil +} + +// tokenExpiry is when a token from the token endpoint expires: expires_in when the response has +// it, else the access token's own exp claim (read, not verified: it only times the cache), else 55 +// minutes from now. +func tokenExpiry(tr tokenResponse, now time.Time) time.Time { 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 now.Add(time.Duration(tr.ExpiresIn) * time.Second) + } + 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 > now.Unix() { + return time.Unix(claims.Exp, 0) + } + } } - return c.svcToken, c.svcTokenExpiry, nil + return now.Add(55 * time.Minute) } // ──────────────────────────────────────────────────────────────────────────── diff --git a/socrate/service_token_test.go b/socrate/service_token_test.go index f42976b..dd623b5 100644 --- a/socrate/service_token_test.go +++ b/socrate/service_token_test.go @@ -2,7 +2,9 @@ package socrate_test import ( "context" + "encoding/base64" "encoding/json" + "fmt" "net/http" "strings" "sync" @@ -116,3 +118,18 @@ func TestServiceToken_Errors(t *testing.T) { 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: 55 minutes. + c, _ = tokenServer(t, "opaque", 0, http.StatusOK) + if _, got, err := c.ServiceToken(context.Background()); err != nil || got.Before(time.Now().Add(54*time.Minute)) { + t.Errorf("opaque token expiry %v (%v)", got, err) + } +} From d6bb2e871684b72a722acead3527e210574cdf2f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 18:38:13 +0000 Subject: [PATCH 3/3] fix(socrate): ServiceToken trusts the token's exp first, counts expires_in from the request Review of #78. The returned expiry is what a caller relies on, so it must never be later than the real one: - the token's own exp claim comes first, as the instant every verifier checks; - otherwise expires_in is counted from just before the request is sent, not from after the response (which was late by the round trip); - a response with neither is trusted for one minute instead of a guessed 55 minutes, longer than a Socrate token lives. Tests: exp preferred over expires_in; expires_in counted from the request (slow server); the no-expiry fallback stays within a minute. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GKRxaeYxyDhmt42cehLsGA --- CHANGELOG.md | 10 +++++---- docs/CLIENT-INTEGRATION.md | 2 +- socrate/client.go | 42 ++++++++++++++++++++++------------- socrate/service_token_test.go | 37 +++++++++++++++++++++++++++--- 4 files changed, 68 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 015b6d2..062a6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ All notable changes to backendkit are documented here. Format: ### Added - `(*socrate.Client).ServiceToken(ctx) (token string, expiresAt time.Time, err error)`: the - application's `client_credentials` access token and its real expiry (`expires_in`, else the - token's `exp` claim; not the cache's early renewal time), for calling another service with + 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). @@ -17,8 +18,9 @@ All notable changes to backendkit are documented here. Format: ### 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. -- Without `expires_in`, the service-account token is cached until its `exp` claim instead of a - fixed 55 minutes (still the fallback for a token without a readable `exp`). +- 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), diff --git a/docs/CLIENT-INTEGRATION.md b/docs/CLIENT-INTEGRATION.md index 02971ef..b21107b 100644 --- a/docs/CLIENT-INTEGRATION.md +++ b/docs/CLIENT-INTEGRATION.md @@ -471,7 +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 (`expires_in`, else the token's `exp`), 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`. | +| `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 diff --git a/socrate/client.go b/socrate/client.go index a9574f8..9ce5aad 100644 --- a/socrate/client.go +++ b/socrate/client.go @@ -285,11 +285,12 @@ func (c *Client) getServiceToken(ctx context.Context) (string, error) { } // ServiceToken returns the application's service-account access token (the client_credentials -// grant, sub=app:{id}) and the time it really expires (from expires_in, else the token's exp -// claim; 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. +// 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() @@ -316,6 +317,9 @@ func (c *Client) ServiceToken(ctx context.Context) (token string, expiresAt time // 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 "", time.Time{}, fmt.Errorf("token exchange: %w", err) @@ -336,28 +340,36 @@ func (c *Client) ServiceToken(ctx context.Context) (token string, expiresAt time return "", time.Time{}, errors.New("token exchange: no access_token in the response") } c.svcToken = tr.AccessToken - c.svcTokenExpiry = tokenExpiry(tr, time.Now()) + c.svcTokenExpiry = tokenExpiry(tr, sent) return c.svcToken, c.svcTokenExpiry, nil } -// tokenExpiry is when a token from the token endpoint expires: expires_in when the response has -// it, else the access token's own exp claim (read, not verified: it only times the cache), else 55 -// minutes from now. -func tokenExpiry(tr tokenResponse, now time.Time) time.Time { - if tr.ExpiresIn > 0 { - return now.Add(time.Duration(tr.ExpiresIn) * time.Second) - } +// 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 > now.Unix() { + if json.Unmarshal(payload, &claims) == nil && claims.Exp > sent.Unix() { return time.Unix(claims.Exp, 0) } } } - return now.Add(55 * time.Minute) + if tr.ExpiresIn > 0 { + return sent.Add(time.Duration(tr.ExpiresIn) * time.Second) + } + return sent.Add(unknownTokenLifetime) } // ──────────────────────────────────────────────────────────────────────────── diff --git a/socrate/service_token_test.go b/socrate/service_token_test.go index dd623b5..05b008c 100644 --- a/socrate/service_token_test.go +++ b/socrate/service_token_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httptest" "strings" "sync" "sync/atomic" @@ -127,9 +128,39 @@ func TestServiceToken_ExpiryFromTheTokenWithoutExpiresIn(t *testing.T) { 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: 55 minutes. + // 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.Before(time.Now().Add(54*time.Minute)) { - t.Errorf("opaque token expiry %v (%v)", got, err) + 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) } }