Skip to content

feat(m2m): client_credentials grant at /oauth/token [Phase 1 — PR 5/5]#647

Merged
lakhansamani merged 2 commits into
feat/workload-identity-phase1from
feat/workload-identity-phase1-client-credentials
Jul 6, 2026
Merged

feat(m2m): client_credentials grant at /oauth/token [Phase 1 — PR 5/5]#647
lakhansamani merged 2 commits into
feat/workload-identity-phase1from
feat/workload-identity-phase1-client-credentials

Conversation

@lakhansamani

Copy link
Copy Markdown
Contributor

Summary

The final PR of Phase 1: the RFC 6749 §4.4 client_credentials OAuth2 grant at /oauth/token. Everything else (storage, service layer, GraphQL, gRPC) is already merged.

Chains on: #645 (merged into this integration branch)

What's in this PR

  • internal/token/auth_token.goAuthTokenConfig.ServiceAccountID; CreateAccessToken branches to a new private createMachineAccessToken when cfg.User == nil && cfg.ServiceAccountID != "" (human path is byte-for-byte unchanged); new public CreateMachineAuthToken — access token only, no id_token, no refresh_token.
  • internal/http_handlers/token.go — new Scope request field, client_credentials grant branch reusing the existing Basic/form client-auth extraction, bcrypt (cost 12) secret verification, scope-subset enforcement, memory-store registration, audit + metrics.
  • internal/storage/schemas/service_account.goParsedAllowedScopes() helper.
  • New constants: AuthRecipeMethodServiceAccount, AuditActorTypeServiceAccount.
  • 10 new integration tests (token_client_credentials_test.go) plus a one-line fix to an existing compliance test that used client_credentials as its "unsupported grant type" example (now uses password, since client_credentials is supported).

Key design decisions (flagging for review)

  • Stateful token validation preserved: access tokens in this codebase are validated against MemoryStoreProvider, not just JWT signature. Machine tokens use login_method: "service_account" so the existing ValidateAccessToken key-derivation (login_method + ":" + sub) reconstructs the same session key the endpoint registers under — zero changes to the validator itself.
  • Default scope when omitted: grants the full AllowedScopes set (common client_credentials convention, not spec-mandated). Requested scopes must be a strict subset — invalid_scope on any excess, no silent downgrade.
  • Timing: every failure path (unknown client_id, inactive account, wrong secret) runs exactly one cost-12 bcrypt compare before returning an identical invalid_client body — inactive-vs-wrong-secret and unknown-vs-wrong-secret are indistinguishable by timing or response shape.
  • No refresh_token/id_token: pure OAuth2, matches RFC 6749 §5.1 response shape exactly.
  • IsActive=false blocks new issuance, not already-issued tokens (until natural expiry) — matches the schema's documented behavior and the existing human-token revocation model.

PR chain

PR 1 — schema + interface + SQL + constants     merged → feat/workload-identity-phase1  ✅ #641
PR 2 — NoSQL provider implementations           merged → feat/workload-identity-phase1  ✅ #642
PR 3 — service layer + admin GraphQL API        merged → feat/workload-identity-phase1  ✅ #644
PR 4 — gRPC admin RPCs                          merged → feat/workload-identity-phase1  ✅ #645
PR 5 — client_credentials grant + tests         🔄 this PR (last one)

Test plan

  • go build ./..., go vet ./..., make lint-go — 0 issues
  • 10 new client_credentials tests — pass
  • Existing token endpoint tests (authorization_code, refresh_token, RFC compliance) — pass, unaffected
  • Full make test-sqlite — pass

Implements the RFC 6749 §4.4 client_credentials grant for service
account (machine/workload) identities — the final PR of Workload
Identity Phase 1.

- Extend AuthTokenConfig with ServiceAccountID; CreateAccessToken
  branches to a machine token (sub=service account id, scope only, no
  roles/allowed_roles, no custom-user script). Human token path is
  unchanged. New CreateMachineAuthToken issues an access token only —
  no id_token, no refresh_token, no session token.
- Machine tokens are stateful like all access tokens: registered in the
  memory store under a namespaced session key (service_account:<id>).
  login_method=service_account makes ValidateAccessToken derive the same
  key with zero changes to the validation path.
- Token endpoint: dual-path client auth (Basic or form body), bcrypt
  secret verification, and a cost-12 dummy-hash timing mitigation so
  unknown vs inactive vs wrong-secret are timing/response indistinguishable.
- Scope enforcement: requested scopes must be a subset of AllowedScopes
  (invalid_scope otherwise, no silent downgrade); omitted scope grants
  the full authorized set. Empty AllowedScopes is deny-all.
- Audit + metrics on issuance, consistent with existing grants.

Adds ParsedAllowedScopes() as the single source of truth for the stored
scope string. Integration tests cover happy paths (Basic + form body),
wrong/unknown/inactive client, scope over-request, omitted scope, and a
full ValidateAccessToken round-trip.
Flagged in review so the global-ClientID audience choice reads as a
scoped decision, not an oversight.
@lakhansamani lakhansamani merged commit f03c237 into feat/workload-identity-phase1 Jul 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant