Add scope-enforcement coverage and integration tests for the oauth-clients CLI across the test matrix.
API v2: Headscale's v2 API
This is Headscale's v2 HTTP API, served at /api/v2. Some of its endpoints are
ported from Tailscale's API, reusing Tailscale's wire shapes, so the
Tailscale ecosystem that cannot talk to Headscale today works: the
Terraform/OpenTofu provider, tscli, and the official Go client
(tailscale.com/client/tailscale/v2).
It is not a port of the whole Tailscale API. Ported endpoints are added one
at a time, only as we need them; a headscale-native v2 endpoint may use
headscale's own conventions. The headscale-native admin API stays at /api/v1
(hscontrol/api/v1). This guide is for the endpoints ported from Tailscale.
Conventions
- Operations derived from Tailscale carry the
Tailscale compattag. - The
{tailnet}path segment must be-(the single Headscale tailnet); anything else is404. SeerequireDefaultTailnet. - Errors use Tailscale's body (
{"message","data","status"}), installed as a per-API transform (tailscaleErrorTransformerinerrors.go). A future headscale-native v2 operation would keep Huma's RFC 9457 problem+json. - Auth accepts a credential as HTTP Basic (key as username, what the SDK
sends) or Bearer: an admin API key (
hskey-api-…), or an OAuth access token (hskey-oauthtok-…). SeeauthMiddleware. - Each operation declares the Tailscale scope it requires (
auth_keys,oauth_keys,devices:core,devices:routes,policy_file,feature_settings, each with a:readsubset, plusall/all:read).requireScoperecords it both for the middleware and in the generated OpenAPI, as anx-required-scopeextension and a sentence in the operation description, so the scope shows up in the docs and spec. Enforcement: an admin API key is all-access (scope checks skipped); an OAuth access token is scope-limited, the middleware checks the operation's declared scope against the token's grant (scope.Grants, where a write scope subsumes its:readandall/all:readare super-scopes). The two are told apart by credential prefix. - Resolve one entity by id with a typed getter (
GetNodeByID,GetUserByID,GetAPIKeyByID,GetPreAuthKeyByID); add one to state/db if it is missing rather than scanning aList. Build responses from the view accessors (NodeView/UserView/PreAuthKeyView), neverAsStruct(). - Reuse upstream wire shapes, but declare the request/response structs here:
Huma reflects these to build the OpenAPI schema, and the upstream
Key'sExpirySeconds *time.Durationmarshals as nanoseconds, which the spec and every client read as seconds.
OAuth clients & scopes
Most of the Tailscale ecosystem (the Terraform provider, tscli, the Go client)
accepts either an API key or OAuth 2.0 client-credentials; the Kubernetes
operator is OAuth-only. Supporting OAuth lets all of them drive Headscale.
- OAuth clients are not a separate resource; they are
keyType:"client"on the keys endpoint, exactly as Tailscale does it. Create (POST /api/v2/tailnet/-/keyswith{"keyType":"client","scopes":[…],"tags":[…]}) returns aKeywhoseidis the client id and whosekeyis the secret, shown once; get/list never re-expose it. The secret ishskey-client-<clientID>-<secret>, embedding the client id so the token endpoint derives it from the secret (Tailscale'sget-authkeytrick). Seekeys.go(createOAuthClient) anddb/oauth.go. - Token endpoint
POST /api/v2/oauth/token(oauth.go) is a plain handler, not a Huma operation: it takesapplication/x-www-form-urlencodedand emits RFC 6749 OAuth2 error bodies ({"error","error_description"}). Credentials arrive in the body or HTTP Basic; optional space-delimitedscope/tagsnarrow the token to a subset of the client's grant. It returns a 1-hourBeareraccess token (hskey-oauthtok-…). - Scope enforcement is the one seam in
authMiddleware. Tag enforcement: an auth key minted by a token may only carry tags the token holds, or tags owned-by them via the policytagOwners(State.TagOwnedByTags→policy/v2), so e.g. an operator token taggedtag:k8s-operatormay minttag:k8skeys. - Credentials/tokens are stored like API keys: a public id/prefix plus an
Argon2id hash of the secret (no JWT, no signing keys).
OAuthClientandOAuthAccessTokenlive intypes/oauth.goanddb/oauth.go.
Adding an endpoint
Worked example: the keys resource (keys.go) = Tailscale auth keys = Headscale
pre-auth keys.
-
Read the Tailscale spec. Find the operation in the Tailscale API reference (OpenAPI 3.1). Note method, path, request/response schema, and which variant(s) Headscale supports (auth keys only, for keys).
-
Capture golden samples. Pull the request + response JSON examples from the spec, prune to the variant, and use them as the assertion in the contract test. Acceptance: the captured request and response are recorded in the test.
-
Map to Headscale. Write the field ↔ field ↔
statecall mapping. Record gaps and the decision for each (e.g. Tailscalepreauthorizedhas no Headscale equivalent: accepted, ignored, echoed back). Acceptance: every request field is consumed or deliberately ignored; every response field has a source. -
Implement the Huma operation. Declare named request/response structs with validation/
default/example/doctags; tag the operationTailscale compat; declare itsErrors; enforce the tailnet and scope. Map state errors withmapError. Acceptance:go build ./hscontrol/api/v2/and the operation appears inSpec(). -
Contract test (in-process,
humatest). Assert the server accepts the golden request and returns the golden response shape, with secrets and timestamps neutralised. Pin the wire facts (e.g.expirySecondsin seconds, the list{"keys":[...]}envelope, the errormessage). Seehscontrol/apiv2_keys_test.go. Acceptance: the test is green. -
Roundtrip the real clients. Add a
t.Runsubtest toTestAPIv2(hscontrol/servertest/apiv2_test.go) for each of the Go client, tscli, and OpenTofu, full create→read→list→delete against one shared server on a real loopback port (servertest.WithRealListener). tscli and tofu come from the nix dev shell; a missing binary fails the test. Acceptance:nix develop -c go test ./hscontrol/servertest/ -run TestAPIv2is green. -
Update the CLI only if the v2 operation fully replaces a v1 one. Tailscale has no separate key-expire verb (its
DELETEis the revoke), so v2 mapsDELETEto a soft revoke: the key stays retrievable withinvalid: trueuntil the collector reaps it (preauth_keys.revoked_retention), the equivalent of v1preauthkeys expire.headscale preauthkeysstill stays on v1 for now (it is the cross-user admin surface), but the verb gap that previously blocked migration is closed.