From 87555ce62167de877d5e85aae2cda452c18b33ab Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sun, 21 Jun 2026 17:35:32 +0000 Subject: [PATCH] api/v2: add OAuth client-credentials auth and scope enforcement --- hscontrol/api/v2/README.md | 59 +++++- hscontrol/api/v2/acl.go | 5 +- hscontrol/api/v2/api.go | 149 +++++++++---- hscontrol/api/v2/devices.go | 32 ++- hscontrol/api/v2/errors.go | 10 +- hscontrol/api/v2/keys.go | 370 ++++++++++++++++++++++++++------- hscontrol/api/v2/oauth.go | 178 ++++++++++++++++ hscontrol/api/v2/oauth_test.go | 31 +++ hscontrol/api/v2/settings.go | 5 +- hscontrol/api/v2/users.go | 5 +- hscontrol/apiv2_keys_test.go | 14 +- hscontrol/scope/scope.go | 6 +- 12 files changed, 708 insertions(+), 156 deletions(-) create mode 100644 hscontrol/api/v2/oauth.go create mode 100644 hscontrol/api/v2/oauth_test.go diff --git a/hscontrol/api/v2/README.md b/hscontrol/api/v2/README.md index 0a643710..90fad363 100644 --- a/hscontrol/api/v2/README.md +++ b/hscontrol/api/v2/README.md @@ -1,7 +1,7 @@ -# API v2 — Headscale's v2 API +# 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 +**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`). @@ -23,12 +23,20 @@ headscale's own conventions. The headscale-native admin API stays at `/api/v1` - Errors use **Tailscale's** body (`{"message","data","status"}`), installed as a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future headscale-native v2 operation would keep Huma's RFC 9457 problem+json. -- Auth accepts the API key as **HTTP Basic** (key as username — what the SDK - sends) or **Bearer**. See `authMiddleware`. -- Each operation declares the Tailscale scope it would require (`auth_keys`, - `devices:core`, `devices:routes`, `policy_file`, `feature_settings`, each with - a `:read` subset). Nothing is enforced yet — every key is all-access — pending - OAuth tokens; see the `TODO(scopes)` in `api.go`. +- 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-…`). See `authMiddleware`. +- Each operation declares the Tailscale scope it requires (`auth_keys`, + `oauth_keys`, `devices:core`, `devices:routes`, `policy_file`, + `feature_settings`, each with a `:read` subset, plus `all`/`all:read`). + `requireScope` records it both for the middleware and in the generated + OpenAPI, as an `x-required-scope` extension 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 + `:read` and `all`/`all:read` are 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 a `List`. Build responses from the view accessors @@ -38,6 +46,35 @@ headscale's own conventions. The headscale-native admin API stays at `/api/v1` `ExpirySeconds *time.Duration` marshals 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/-/keys` with `{"keyType":"client","scopes":[…],"tags":[…]}`) + returns a `Key` whose `id` is the client id and whose `key` is the secret, + **shown once**; get/list never re-expose it. The secret is + `hskey-client--`, embedding the client id so the token + endpoint derives it from the secret (Tailscale's `get-authkey` trick). See + `keys.go` (`createOAuthClient`) and `db/oauth.go`. +- **Token endpoint** `POST /api/v2/oauth/token` (`oauth.go`) is a plain handler, + not a Huma operation: it takes `application/x-www-form-urlencoded` and emits + RFC 6749 OAuth2 error bodies (`{"error","error_description"}`). Credentials + arrive in the body or HTTP Basic; optional space-delimited `scope`/`tags` + narrow the token to a subset of the client's grant. It returns a 1-hour + `Bearer` access 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 policy `tagOwners` (`State.TagOwnedByTags` → + `policy/v2`), so e.g. an operator token tagged `tag:k8s-operator` may mint + `tag:k8s` keys. +- Credentials/tokens are stored like API keys: a public id/prefix plus an + **Argon2id** hash of the secret (no JWT, no signing keys). `OAuthClient` and + `OAuthAccessToken` live in `types/oauth.go` and `db/oauth.go`. + ## Adding an endpoint Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale @@ -55,7 +92,7 @@ pre-auth keys. 3. **Map to Headscale.** Write the field ↔ field ↔ `state` call mapping. Record gaps and the decision for each (e.g. Tailscale `preauthorized` has no - Headscale equivalent — accepted, ignored, echoed back). _Acceptance: every + Headscale equivalent: accepted, ignored, echoed back). _Acceptance: every request field is consumed or deliberately ignored; every response field has a source._ @@ -73,13 +110,13 @@ compat`; declare its `Errors`; enforce the tailnet and scope. Map state 6. **Roundtrip the real clients.** Add a `t.Run` subtest to `TestAPIv2` (`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 + 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 TestAPIv2` is green._ 7. **Update the CLI** only if the v2 operation fully replaces a v1 one. Tailscale - has no separate key-expire verb — its `DELETE` _is_ the revoke — so v2 maps + has no separate key-expire verb (its `DELETE` _is_ the revoke), so v2 maps `DELETE` to a soft revoke: the key stays retrievable with `invalid: true` until the collector reaps it (`preauth_keys.revoked_retention`), the equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on diff --git a/hscontrol/api/v2/acl.go b/hscontrol/api/v2/acl.go index 51bd25ce..ff691793 100644 --- a/hscontrol/api/v2/acl.go +++ b/hscontrol/api/v2/acl.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" ) @@ -60,7 +61,7 @@ func registerACL(api huma.API, b Backend) { Tags: aclTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError}, - }, ScopePolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) { + }, scope.PolicyFileRead), func(ctx context.Context, in *getACLInput) (*huma.StreamResponse, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err @@ -89,7 +90,7 @@ func registerACL(api huma.API, b Backend) { http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError, }, - }, ScopePolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) { + }, scope.PolicyFile), func(ctx context.Context, in *setACLInput) (*huma.StreamResponse, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go index a2a8b887..e42c51b8 100644 --- a/hscontrol/api/v2/api.go +++ b/hscontrol/api/v2/api.go @@ -1,8 +1,8 @@ // Package apiv2 is Headscale's v2 HTTP API, served at /api/v2. // // Where the v1 API (hscontrol/api/v1) is the headscale-native admin surface, v2 -// additionally ports selected endpoints from Tailscale's API — reusing -// Tailscale's wire shapes (paths, request/response JSON, error body) — so the +// additionally ports selected endpoints from Tailscale's API, reusing +// Tailscale's wire shapes (paths, request/response JSON, error body), so the // existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and // tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported // operations carry the "Tailscale compat" tag; a headscale-native v2 operation @@ -24,6 +24,7 @@ import ( "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humachi" "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/state" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types/change" @@ -40,7 +41,7 @@ type Backend struct { } // security is the requirement applied to authenticated operations: an API key -// presented as HTTP Basic (the key as username — what the Tailscale SDK sends) +// presented as HTTP Basic (the key as username, what the Tailscale SDK sends) // or as a Bearer token. var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}} @@ -79,7 +80,7 @@ func Config() huma.Config { // Accept application/hujson request bodies (the Tailscale SDK sends the // policy file that way). The bytes are captured raw by the ACL handler, so // reusing the JSON format is only to satisfy huma's content-type check. - // Clone first — config.Formats aliases huma's shared DefaultFormats map. + // Clone first: config.Formats aliases huma's shared DefaultFormats map. formats := maps.Clone(config.Formats) formats["application/hujson"] = formats["application/json"] config.Formats = formats @@ -98,6 +99,10 @@ func NewAPI(router chi.Router, backend Backend) huma.API { Register(api, backend) + // The OAuth token endpoint is a plain route, not a Huma operation (see + // oauth.go); register it on the same router. + registerOAuthToken(router, backend) + return api } @@ -117,11 +122,21 @@ func Spec() ([]byte, error) { return api.OpenAPI().YAML() } +// Spec30 emits the document downgraded to OpenAPI 3.0.3, needed because +// oapi-codegen cannot yet read 3.1; the typed client is generated from this. +func Spec30() ([]byte, error) { + api := NewAPI(chi.NewMux(), Backend{}) + + return api.OpenAPI().DowngradeYAML() +} + type contextKey int const ( localTrustKey contextKey = iota ownerUserKey + principalScopesKey + principalTagsKey ) // WithLocalTrust marks a request as arriving over a locally-trusted transport @@ -136,8 +151,8 @@ func WithLocalTrust(next http.Handler) http.Handler { } // authMiddleware authenticates the API key (HTTP Basic with the key as the -// username — what the Tailscale SDK sends — or Bearer), records the key's -// owning user for handlers, and enforces the operation's required scope. +// username, what the Tailscale SDK sends, or Bearer), records the key's owning +// user for handlers, and enforces the operation's required scope. func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { if ctx.Context().Value(localTrustKey) != nil { @@ -159,6 +174,35 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex return } + // An OAuth access token is scope-limited; an admin API key is all-access. + // They are told apart by prefix so a scoped token can never be mistaken + // for an all-access key. + if strings.HasPrefix(token, types.AccessTokenPrefix) { + at, err := b.State.AuthenticateAccessToken(token) + if err != nil { + _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") + + return + } + + if want, ok := requiredScope(ctx.Operation()); ok && !scope.Grants(scope.Parse(at.Scopes), want) { + _ = huma.WriteErr(api, ctx, http.StatusForbidden, + "token is missing the required scope "+string(want)) + + return + } + + // The keys handler multiplexes on keyType, so its required scope and + // permitted tags depend on the body; carry the token's scopes and tags + // for it to finish the check the static middleware cannot. + ctx = huma.WithValue(ctx, principalScopesKey, at.Scopes) + ctx = huma.WithValue(ctx, principalTagsKey, at.Tags) + + next(ctx) + + return + } + key, err := b.State.AuthenticateAPIKey(token) if err != nil { _ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") @@ -166,13 +210,9 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex return } - // TODO(scopes): every valid key is all-access today. Operations still - // declare their required scope (requireScope) so that once OAuth tokens - // arrive, the granted set can be derived from the token and checked - // against the operation's scope here. Until then, accept everything. - - // Record the key's owning user (may be unset) so handlers can create - // user-owned keys on its behalf. + // An admin API key is all-access: its operations are not scope-checked. + // Record its owning user (may be unset) so handlers can create user-owned + // keys on its behalf. if key.UserID != nil { ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID)) } @@ -210,6 +250,24 @@ func ownerUser(ctx context.Context) (types.UserID, bool) { return uid, ok } +// principalScopes returns the scopes granted to the request's OAuth access +// token, and whether the request authenticated with one. ok is false for an +// admin API key, which is all-access and not scope-checked. +func principalScopes(ctx context.Context) ([]string, bool) { + scopes, ok := ctx.Value(principalScopesKey).([]string) + + return scopes, ok +} + +// principalTags returns the tags granted to the request's OAuth access token, +// and whether the request authenticated with one. An admin API key is not an +// OAuth token, so ok is false and its key creation is unrestricted by tags. +func principalTags(ctx context.Context) ([]string, bool) { + tags, ok := ctx.Value(principalTagsKey).([]string) + + return tags, ok +} + // requireDefaultTailnet rejects any tailnet other than "-". Headscale is // single-tailnet; the Tailscale SDK sends "-" (its default tailnet). A non-"-" // value is "no such tailnet", a 404, which lets the SDK's IsNotFound behave. @@ -221,46 +279,47 @@ func requireDefaultTailnet(tailnet string) error { return nil } -// Scope is an OAuth capability an operation requires and a token grants. The -// names mirror Tailscale's API scopes (see the OAuth scope descriptions in the -// Tailscale OpenAPI spec); a ...Read scope is the read-only subset of its -// write scope. Nothing is enforced yet — every key is all-access — but every -// operation declares the scope it would require, so OAuth tokens can later be -// checked against it without reworking the operations. -type Scope string +// The scope vocabulary and the grant predicate live in the hscontrol/scope +// package; this file only wires a required scope onto each huma operation and +// reads it back in the middleware. -const ( - ScopeAuthKeys Scope = "auth_keys" - ScopeAuthKeysRead Scope = "auth_keys:read" - - ScopeDevicesCore Scope = "devices:core" - ScopeDevicesCoreRead Scope = "devices:core:read" - - ScopeDevicesRoutes Scope = "devices:routes" - ScopeDevicesRoutesRead Scope = "devices:routes:read" - - ScopePolicyFile Scope = "policy_file" - ScopePolicyFileRead Scope = "policy_file:read" - - ScopeFeatureSettings Scope = "feature_settings" - ScopeFeatureSettingsRead Scope = "feature_settings:read" - - ScopeUsers Scope = "users" - ScopeUsersRead Scope = "users:read" -) - -// scopeMetaKey keys the per-operation required Scope in huma.Operation.Metadata. +// scopeMetaKey keys the per-operation required scope in huma.Operation.Metadata. const scopeMetaKey = "headscale.scope" -// requireScope records op's required scope in its Metadata, where the auth -// middleware can read it back. It keeps the requirement next to the operation -// definition. -func requireScope(op huma.Operation, s Scope) huma.Operation { +// requireScope records op's required scope, both in its Metadata (where the auth +// middleware reads it back) and in the generated OpenAPI document: an +// x-required-scope extension for machine consumers and a Description line so the +// rendered docs state what each operation needs. +func requireScope(op huma.Operation, s scope.Scope) huma.Operation { if op.Metadata == nil { op.Metadata = map[string]any{} } op.Metadata[scopeMetaKey] = s + if op.Extensions == nil { + op.Extensions = map[string]any{} + } + + op.Extensions["x-required-scope"] = string(s) + + note := "Requires the `" + string(s) + "` OAuth scope (an admin API key is all-access)." + if op.Description == "" { + op.Description = note + } else { + op.Description += "\n\n" + note + } + return op } + +// requiredScope returns the scope an operation declared via requireScope, if any. +func requiredScope(op *huma.Operation) (scope.Scope, bool) { + if op == nil || op.Metadata == nil { + return "", false + } + + s, ok := op.Metadata[scopeMetaKey].(scope.Scope) + + return s, ok +} diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index 23eb7cac..5bdb553f 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -8,6 +8,7 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" "tailscale.com/net/tsaddr" @@ -125,7 +126,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) { + }, scope.DevicesCoreRead), func(ctx context.Context, in *deviceByIDInput) (*deviceOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -142,7 +143,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) { + }, scope.DevicesCoreRead), func(ctx context.Context, in *listDevicesInput) (*listDevicesOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err @@ -170,7 +171,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *deviceByIDInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -195,7 +196,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) { _, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -222,7 +223,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -247,7 +248,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -261,6 +262,19 @@ func registerDevices(api huma.API, b Backend) { return &emptyOutput{}, nil } + // An OAuth token may only assign tags within its grant (held directly or + // owned by a held tag per policy); an admin API key is unrestricted. The + // devices:core scope alone must not let a token stamp an arbitrary policy + // tag (e.g. tag:prod) onto any node. SetNodeTags still enforces that each + // tag exists in policy. + if tokenTags, isOAuth := principalTags(ctx); isOAuth { + for _, tag := range in.Body.Tags { + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden("token may not assign tag " + tag) + } + } + } + _, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags) if err != nil { return nil, mapError("setting device tags", err) @@ -280,7 +294,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) { + }, scope.DevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -313,7 +327,7 @@ func registerDevices(api huma.API, b Backend) { Security: security, DefaultStatus: http.StatusOK, Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) { + }, scope.DevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err @@ -342,7 +356,7 @@ func registerDevices(api huma.API, b Backend) { Tags: deviceTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeDevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) { + }, scope.DevicesRoutesRead), func(ctx context.Context, in *deviceByIDInput) (*deviceRoutesOutput, error) { node, err := lookupNode(b, in.DeviceID) if err != nil { return nil, err diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 7bd25c4f..cf5110e2 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -9,12 +9,12 @@ import ( "gorm.io/gorm" ) -// apiError is the Tailscale API error body. The official Tailscale Go client — -// and therefore the Terraform provider and tscli built on it — decode 4xx/5xx +// apiError is the Tailscale API error body. The official Tailscale Go client +// (and therefore the Terraform provider and tscli built on it) decodes 4xx/5xx // responses into this shape. Huma's default RFC 9457 problem+json would reach -// them with an empty message, so every v2 error is rewritten into this shape by -// tailscaleErrorTransformer. The HTTP status is read from the response code, -// not from the body's status field. +// them with an empty message, so tailscaleErrorTransformer rewrites every v2 +// error into this shape. The HTTP status is read from the response code, not +// from the body's status field. type apiError struct { Message string `json:"message"` Data []apiErrorData `json:"data,omitempty"` diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go index 9ce5f26f..55210913 100644 --- a/hscontrol/api/v2/keys.go +++ b/hscontrol/api/v2/keys.go @@ -3,10 +3,13 @@ package apiv2 import ( "context" "net/http" + "strconv" "time" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" + "github.com/juanfont/headscale/hscontrol/util" ) func init() { @@ -17,9 +20,13 @@ func init() { // 90 days, matching Tailscale and the Terraform provider default. const defaultExpiry = 90 * 24 * time.Hour -// keyTypeAuth is the only key kind Headscale issues; Tailscale's OAuth-client -// and federated-identity kinds are out of scope. -const keyTypeAuth = "auth" +const ( + // keyTypeAuth is a machine auth key (Headscale pre-auth key); the default. + keyTypeAuth = "auth" + // keyTypeClient is an OAuth client (client-credentials). Multiplexed onto the + // keys resource exactly as Tailscale does. + keyTypeClient = "client" +) // KeyCapabilities maps a resource to the actions a key permits. Headscale // populates only devices.create (auth keys); the named types (vs Tailscale's @@ -42,16 +49,27 @@ type KeyDeviceCreateCapabilities struct { Tags []string `json:"tags"` } -// CreateKeyRequest is the POST body. expirySeconds is plain seconds (unlike the -// response, see Key). +// CreateKeyRequest is the POST body. It is multiplexed by keyType: "auth" +// (default) creates a machine auth key from Capabilities; "client" creates an +// OAuth client from the top-level Scopes and Tags. expirySeconds is plain +// seconds (unlike the response, see Key). type CreateKeyRequest struct { - Capabilities KeyCapabilities `json:"capabilities"` - ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days." json:"expirySeconds,omitempty"` - Description string `json:"description,omitempty" maxLength:"50"` + KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"` + // Capabilities is optional: an auth key carries device-create capabilities, + // but an OAuth client (keyType:"client") has none and the Tailscale clients + // omit the field entirely, so it must not be required. + Capabilities *KeyCapabilities `json:"capabilities,omitempty"` + ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days. Auth keys only." json:"expirySeconds,omitempty"` + Description string `json:"description,omitempty" maxLength:"50"` + // Scopes and Tags are top-level and apply only to keyType "client" (an OAuth + // client). Auth-key tags live under Capabilities.Devices.Create.Tags. + Scopes []string `doc:"OAuth scopes granted to the client. keyType=client only." json:"scopes,omitempty"` + Tags []string `doc:"Tags the client may assign. keyType=client only." json:"tags,omitempty"` } -// Key is the Tailscale auth-key response. expirySeconds is emitted in seconds -// to match the Tailscale spec; the secret key is present only at creation. +// Key is the Tailscale key response, shared by auth keys and OAuth clients. +// expirySeconds is emitted in seconds to match the Tailscale spec; the secret +// key is present only at creation. type Key struct { ID string `json:"id"` KeyType string `json:"keyType"` @@ -63,6 +81,7 @@ type Key struct { Revoked *time.Time `json:"revoked,omitempty"` Invalid bool `json:"invalid"` Capabilities KeyCapabilities `json:"capabilities"` + Scopes []string `json:"scopes,omitempty"` Tags []string `json:"tags,omitempty"` UserID string `json:"userId,omitempty"` } @@ -98,14 +117,19 @@ type ( } ) +// The keys resource is multiplexed by keyType (auth key vs OAuth client), so the +// scope an operation requires depends on the request rather than being fixed. +// requireScope (which the middleware enforces statically) is therefore omitted +// here; each handler authorizes via requireKeyScope once the kind is known. func registerKeys(api huma.API, b Backend) { keysTags := []string{"Keys", "Tailscale compat"} - huma.Register(api, requireScope(huma.Operation{ + huma.Register(api, huma.Operation{ OperationID: "createKey", Method: http.MethodPost, Path: "/api/v2/tailnet/{tailnet}/keys", - Summary: "Create an auth key", + Summary: "Create an auth key or OAuth client", + Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).", Tags: keysTags, Security: security, Errors: []int{ @@ -114,63 +138,25 @@ func registerKeys(api huma.API, b Backend) { http.StatusForbidden, http.StatusNotFound, }, - }, ScopeAuthKeys), func(ctx context.Context, in *createKeyInput) (*keyOutput, error) { + }, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err } - create := in.Body.Capabilities.Devices.Create - - // Ownership: tags -> tagged key; no tags -> owned by the API key's user; - // no tags and an ownerless (legacy/admin) key -> 400. - var userID *types.UserID - - if len(create.Tags) == 0 { - uid, ok := ownerUser(ctx) - if !ok { - return nil, huma.Error400BadRequest( - "an auth key without tags must be created with a user-owned API key", - ) - } - - userID = &uid + if in.Body.KeyType == keyTypeClient { + return createOAuthClient(ctx, b, in.Body) } - expiration := time.Now().Add(expiryDuration(in.Body.ExpirySeconds)) - - pak, err := b.State.CreatePreAuthKey( - userID, - create.Reusable, - create.Ephemeral, - &expiration, - create.Tags, - ) - if err != nil { - return nil, mapError("creating auth key", err) - } - - if in.Body.Description != "" { - err := b.State.SetPreAuthKeyDescription( - pak.ID, - in.Body.Description, - ) - if err != nil { - return nil, huma.Error500InternalServerError( - "setting auth key description", - err, - ) - } - } - - return &keyOutput{Body: keyFromNew(pak, in.Body)}, nil + return createAuthKey(ctx, b, in.Body) }) - huma.Register(api, requireScope(huma.Operation{ + huma.Register(api, huma.Operation{ OperationID: "listKeys", Method: http.MethodGet, Path: "/api/v2/tailnet/{tailnet}/keys", - Summary: "List auth keys", + Summary: "List auth keys and OAuth clients", + Description: "A token sees the kinds it can read: `auth_keys:read` for auth keys, `oauth_keys:read` for OAuth clients (an admin API key sees all).", Tags: keysTags, Security: security, Errors: []int{ @@ -178,32 +164,49 @@ func registerKeys(api huma.API, b Backend) { http.StatusForbidden, http.StatusNotFound, }, - }, ScopeAuthKeysRead), func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) { + }, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err } - keys, err := b.State.ListPreAuthKeys() - if err != nil { - return nil, huma.Error500InternalServerError("listing auth keys", err) - } + scopes, isOAuth := principalScopes(ctx) out := &listKeysOutput{} - out.Body.Keys = make([]Key, 0, len(keys)) + out.Body.Keys = []Key{} - for i := range keys { - out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) + // A token sees the key kinds it has read scope for; an admin key sees all. + if !isOAuth || scope.Grants(scope.Parse(scopes), scope.AuthKeysRead) { + keys, err := b.State.ListPreAuthKeys() + if err != nil { + return nil, huma.Error500InternalServerError("listing auth keys", err) + } + + for i := range keys { + out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) + } + } + + if !isOAuth || scope.Grants(scope.Parse(scopes), scope.OAuthKeysRead) { + clients, err := b.State.ListOAuthClients() + if err != nil { + return nil, huma.Error500InternalServerError("listing oauth clients", err) + } + + for i := range clients { + out.Body.Keys = append(out.Body.Keys, oauthClientToKey(&clients[i], "")) + } } return out, nil }) - huma.Register(api, requireScope(huma.Operation{ + huma.Register(api, huma.Operation{ OperationID: "getKey", Method: http.MethodGet, Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", - Summary: "Get an auth key", + Summary: "Get an auth key or OAuth client", + Description: "Requires `auth_keys:read` for an auth key, or `oauth_keys:read` for an OAuth client (an admin API key is all-access).", Tags: keysTags, Security: security, Errors: []int{ @@ -211,12 +214,29 @@ func registerKeys(api huma.API, b Backend) { http.StatusForbidden, http.StatusNotFound, }, - }, ScopeAuthKeysRead), func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) { + }, func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err } + // An OAuth client id is a hex string distinct from a numeric auth-key id, + // so a client lookup that hits is authoritative; otherwise fall through to + // the auth-key path. The lookup is gated on the caller actually holding + // oauth_keys:read so a token without it cannot tell a real client id (403) + // from an unknown key (404) — i.e. no client-existence oracle. + if requireKeyScope(ctx, scope.OAuthKeysRead) == nil { + client, err := b.State.GetOAuthClientByClientID(in.KeyID) + if err == nil { + return &keyOutput{Body: oauthClientToKey(client, "")}, nil + } + } + + err = requireKeyScope(ctx, scope.AuthKeysRead) + if err != nil { + return nil, err + } + key, err := findKeyByID(b, in.KeyID) if err != nil { return nil, err @@ -225,11 +245,12 @@ func registerKeys(api huma.API, b Backend) { return &keyOutput{Body: keyFromStored(key)}, nil }) - huma.Register(api, requireScope(huma.Operation{ + huma.Register(api, huma.Operation{ OperationID: "deleteKey", Method: http.MethodDelete, Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", - Summary: "Delete an auth key", + Summary: "Delete an auth key or OAuth client", + Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).", Tags: keysTags, Security: security, DefaultStatus: http.StatusOK, @@ -238,12 +259,31 @@ func registerKeys(api huma.API, b Backend) { http.StatusForbidden, http.StatusNotFound, }, - }, ScopeAuthKeys), func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) { + }, func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err } + // Gated on oauth_keys (write) for the same no-existence-oracle reason as + // getKey: a token without it must not learn that an id is an OAuth client. + if requireKeyScope(ctx, scope.OAuthKeys) == nil { + _, err = b.State.GetOAuthClientByClientID(in.KeyID) + if err == nil { + err = b.State.RevokeOAuthClient(in.KeyID) + if err != nil { + return nil, mapError("deleting oauth client", err) + } + + return &deleteKeyOutput{}, nil + } + } + + err = requireKeyScope(ctx, scope.AuthKeys) + if err != nil { + return nil, err + } + id, err := parseID(in.KeyID, "auth key") if err != nil { return nil, err @@ -261,6 +301,163 @@ func registerKeys(api huma.API, b Backend) { }) } +// requireKeyScope authorizes a keys operation for an OAuth access token. An admin +// API key carries no OAuth scopes and is all-access, so it always passes. +func requireKeyScope(ctx context.Context, need scope.Scope) error { + scopes, isOAuth := principalScopes(ctx) + if !isOAuth { + return nil + } + + if !scope.Grants(scope.Parse(scopes), need) { + return huma.Error403Forbidden("token is missing the required scope " + string(need)) + } + + return nil +} + +// createAuthKey creates a machine auth key (pre-auth key). Ownership: tags -> a +// tagged key; no tags -> owned by the API key's user. An OAuth access token must +// mint tagged keys, and each tag must be within the token's grant. +func createAuthKey(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) { + err := requireKeyScope(ctx, scope.AuthKeys) + if err != nil { + return nil, err + } + + var create KeyDeviceCreateCapabilities + if body.Capabilities != nil { + create = body.Capabilities.Devices.Create + } + + tokenTags, isOAuth := principalTags(ctx) + + var userID *types.UserID + + switch { + case len(create.Tags) > 0: + // A key minted by an OAuth token may only carry tags within the token's + // grant (held directly, or owned by a held tag) and defined in policy, + // matching SetNodeTags. An admin key keeps the historical behaviour of + // validating only tag syntax (db.validateACLTags). + if isOAuth { + for _, tag := range create.Tags { + if !b.State.TagExists(tag) { + return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy") + } + + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden( + "token may not assign tag " + tag, + ) + } + } + } + + case isOAuth: + // OAuth-minted keys are tailnet/tag-owned; an untagged (user-owned) key + // cannot be created from a token. + return nil, huma.Error403Forbidden("an OAuth client must create tagged auth keys") + + default: + uid, ok := ownerUser(ctx) + if !ok { + return nil, huma.Error400BadRequest( + "an auth key without tags must be created with a user-owned API key", + ) + } + + userID = &uid + } + + expiration := time.Now().Add(expiryDuration(body.ExpirySeconds)) + + pak, err := b.State.CreatePreAuthKey( + userID, + create.Reusable, + create.Ephemeral, + &expiration, + create.Tags, + ) + if err != nil { + return nil, mapError("creating auth key", err) + } + + if body.Description != "" { + err := b.State.SetPreAuthKeyDescription(pak.ID, body.Description) + if err != nil { + return nil, huma.Error500InternalServerError("setting auth key description", err) + } + } + + return &keyOutput{Body: keyFromNew(pak, body)}, nil +} + +// createOAuthClient creates an OAuth client (keyType:"client"). The client secret +// is returned once, here. +func createOAuthClient(ctx context.Context, b Backend, body CreateKeyRequest) (*keyOutput, error) { + err := requireKeyScope(ctx, scope.OAuthKeys) + if err != nil { + return nil, err + } + + if len(body.Scopes) == 0 { + return nil, huma.Error400BadRequest("an OAuth client must declare at least one scope") + } + + // Tailscale: tags are mandatory when the scopes include devices:core or + // auth_keys, because such a client mints tagged, tailnet-owned credentials. + if scope.RequiresTags(scope.Parse(body.Scopes)) && len(body.Tags) == 0 { + return nil, huma.Error400BadRequest( + "tags are required when scopes include devices:core or auth_keys", + ) + } + + // A client created by an OAuth token may not be granted authority the token + // lacks: its scopes must each be within the token's grant, and its tags within + // the token's tags and defined in policy (matching SetNodeTags). Otherwise an + // oauth_keys token could mint an all-access client and escalate. An admin API + // key (not an OAuth token) is unrestricted and keeps the historical tag + // behaviour (syntax-only validation). + if tokenScopes, isOAuth := principalScopes(ctx); isOAuth { + for _, s := range body.Scopes { + if !scope.Grants(scope.Parse(tokenScopes), scope.Scope(s)) { + return nil, huma.Error403Forbidden( + "client may not be granted scope " + s + " beyond the creating token", + ) + } + } + + tokenTags, _ := principalTags(ctx) + + for _, tag := range body.Tags { + if !b.State.TagExists(tag) { + return nil, huma.Error400BadRequest("tag " + tag + " is not defined in policy") + } + + if !b.State.TagOwnedByTags(tag, tokenTags) { + return nil, huma.Error403Forbidden( + "client may not be granted tag " + tag + " beyond the creating token", + ) + } + } + } + + var creator *uint + + if uid, ok := ownerUser(ctx); ok { + u := uint(uid) + creator = &u + } + + secret, client, err := b.State.CreateOAuthClient(body.Scopes, body.Tags, body.Description, creator) + if err != nil { + return nil, mapError("creating oauth client", err) + } + + return &keyOutput{Body: oauthClientToKey(client, secret)}, nil +} + // findKeyByID looks up a stored pre-auth key by its (stringified) id with a // direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which // mapError turns into a 404. @@ -283,7 +480,10 @@ func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) { // match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the // create and read paths must agree or the Terraform provider sees a diff. func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key { - create := req.Capabilities.Devices.Create + var create KeyDeviceCreateCapabilities + if req.Capabilities != nil { + create = req.Capabilities.Devices.Create + } key := Key{ ID: pak.StringID(), @@ -344,6 +544,32 @@ func keyFromStored(pak *types.PreAuthKey) Key { return key } +// oauthClientToKey builds the keys response for an OAuth client. secret is the +// plaintext client secret, set only on the create response and empty on get/list +// (the secret is never re-exposed). +func oauthClientToKey(client *types.OAuthClient, secret string) Key { + key := Key{ + ID: client.ClientID, + KeyType: keyTypeClient, + Key: secret, + Description: client.Description, + Created: timeOrZero(client.CreatedAt), + Scopes: emptyIfNil(client.Scopes), + Tags: emptyIfNil(client.Tags), + } + + if client.Revoked != nil { + key.Revoked = client.Revoked + key.Invalid = true + } + + if client.UserID != nil { + key.UserID = strconv.FormatUint(uint64(*client.UserID), util.Base10) + } + + return key +} + func capabilities( reusable, ephemeral, preauthorized bool, tags []string, diff --git a/hscontrol/api/v2/oauth.go b/hscontrol/api/v2/oauth.go new file mode 100644 index 00000000..330c94f9 --- /dev/null +++ b/hscontrol/api/v2/oauth.go @@ -0,0 +1,178 @@ +package apiv2 + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/juanfont/headscale/hscontrol/types" +) + +// accessTokenTTL is the fixed lifetime of a minted access token, matching +// Tailscale's non-configurable one hour. +const accessTokenTTL = time.Hour + +// registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain +// handler, not a Huma operation: it consumes application/x-www-form-urlencoded +// and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither +// of which fits Huma's JSON-in / Tailscale-error-out machinery. +// ponytail: one bespoke OAuth endpoint isn't worth bending Huma around. +func registerOAuthToken(router chi.Router, b Backend) { + router.Post("/api/v2/oauth/token", oauthTokenHandler(b)) +} + +// tokenResponse is the OAuth 2.0 client-credentials success body. +type tokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope,omitempty"` +} + +// oauthTokenHandler implements the client-credentials grant (RFC 6749 §4.4): +// authenticate the client, optionally narrow the granted scopes/tags, and mint a +// short-lived bearer token. +func oauthTokenHandler(b Backend) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body") + + return + } + + // grant_type defaults to client_credentials: Tailscale's documented curl + // omits it, and the x/oauth2 client always sends it. + if gt := r.PostForm.Get("grant_type"); gt != "" && gt != "client_credentials" { + writeOAuthError(w, http.StatusBadRequest, "unsupported_grant_type", + "only the client_credentials grant is supported") + + return + } + + // Credentials may arrive in the body or as HTTP Basic (the x/oauth2 + // auto-detect probes Basic first). The secret embeds the client id, so a + // separate client_id is not required. + secret := r.PostForm.Get("client_secret") + if secret == "" { + if _, pass, ok := r.BasicAuth(); ok { + secret = pass + } + } + + if secret == "" { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "missing client credentials") + + return + } + + client, err := b.State.AuthenticateOAuthClient(secret) + if err != nil { + writeOAuthError(w, http.StatusUnauthorized, "invalid_client", "invalid client credentials") + + return + } + + // Optional space-delimited scope/tags narrow the token to a subset of the + // client's grant. + scopes, badScope, ok := narrowScopes(client.Scopes, strings.Fields(r.PostForm.Get("scope"))) + if !ok { + writeOAuthError(w, http.StatusBadRequest, "invalid_scope", + "scope "+badScope+" is not granted to this client") + + return + } + + tags, badTag, ok := narrowTags(client, strings.Fields(r.PostForm.Get("tags"))) + if !ok { + writeOAuthError(w, http.StatusBadRequest, "invalid_target", + "tag "+badTag+" is not granted to this client") + + return + } + + expiry := time.Now().Add(accessTokenTTL) + + tokenStr, _, err := b.State.MintAccessToken(client.ClientID, scopes, tags, &expiry) + if err != nil { + writeOAuthError(w, http.StatusInternalServerError, "server_error", "could not mint access token") + + return + } + + writeJSON(w, http.StatusOK, tokenResponse{ + AccessToken: tokenStr, + TokenType: "Bearer", + ExpiresIn: int(accessTokenTTL.Seconds()), + Scope: strings.Join(scopes, " "), + }) + } +} + +// narrowScopes returns the requested scopes if each is granted by the client (an +// empty request means "the client's full grant"), otherwise the offending scope +// and false. A client holding a broad scope (e.g. "all") may mint a token limited +// to a narrower one. +func narrowScopes(granted, requested []string) ([]string, string, bool) { + if len(requested) == 0 { + return granted, "", true + } + + for _, req := range requested { + if !scope.Grants(scope.Parse(granted), scope.Scope(req)) { + return nil, req, false + } + } + + return requested, "", true +} + +// narrowTags returns the requested tags if each is within the client's grant (an +// empty request means "the client's full tag set"), otherwise the offending tag +// and false. A client with the "all" scope may assign any tag, matching Tailscale. +func narrowTags(client *types.OAuthClient, requested []string) ([]string, string, bool) { + if len(requested) == 0 { + return client.Tags, "", true + } + + allScope := slices.Contains(client.Scopes, string(scope.All)) + + for _, req := range requested { + // Reject malformed tags at the trust boundary. A client's own tags are + // validated at creation, so this only matters for an "all"-scope client, + // whose tags would otherwise skip the membership check below and flow + // unvalidated into auth-key creation. + if !strings.HasPrefix(req, "tag:") { + return nil, req, false + } + + if !allScope && !slices.Contains(client.Tags, req) { + return nil, req, false + } + } + + return requested, "", true +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + // Token responses carry bearer credentials; RFC 6749 §5.1 forbids caching + // them. writeJSON serves only the token endpoint, so set it unconditionally. + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) //nolint:errchkjson // best-effort response write of a known-safe value +} + +// writeOAuthError emits an RFC 6749 §5.2 error body, which the x/oauth2 client +// parses into its RetrieveError. +func writeOAuthError(w http.ResponseWriter, status int, code, desc string) { + writeJSON(w, status, map[string]string{ + "error": code, + "error_description": desc, + }) +} diff --git a/hscontrol/api/v2/oauth_test.go b/hscontrol/api/v2/oauth_test.go new file mode 100644 index 00000000..1abbacf8 --- /dev/null +++ b/hscontrol/api/v2/oauth_test.go @@ -0,0 +1,31 @@ +package apiv2 + +import ( + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" +) + +// TestNarrowTagsRejectsMalformed asserts the token endpoint validates tag +// format at the trust boundary. An "all"-scope client may assign any tag, but a +// malformed tag (missing the "tag:" prefix) must still be rejected rather than +// flowing unvalidated into auth-key creation. +func TestNarrowTagsRejectsMalformed(t *testing.T) { + all := &types.OAuthClient{Scopes: []string{"all"}} + + _, bad, ok := narrowTags(all, []string{"not-a-tag"}) + assert.False(t, ok, "malformed tag must be rejected even with all scope") + assert.Equal(t, "not-a-tag", bad) + + got, _, ok := narrowTags(all, []string{"tag:anything"}) + assert.True(t, ok, "all scope may assign any well-formed tag") + assert.Equal(t, []string{"tag:anything"}, got) + + // A scoped client may only assign its own well-formed tags. + scoped := &types.OAuthClient{Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}} + + _, bad, ok = narrowTags(scoped, []string{"tag:other"}) + assert.False(t, ok) + assert.Equal(t, "tag:other", bad) +} diff --git a/hscontrol/api/v2/settings.go b/hscontrol/api/v2/settings.go index 58628370..262ec554 100644 --- a/hscontrol/api/v2/settings.go +++ b/hscontrol/api/v2/settings.go @@ -7,6 +7,7 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" ) @@ -59,7 +60,7 @@ func registerSettings(api huma.API, b Backend) { Tags: settingsTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeFeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) { + }, scope.FeatureSettingsRead), func(ctx context.Context, in *getSettingsInput) (*settingsOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err @@ -86,7 +87,7 @@ func registerSettings(api huma.API, b Backend) { // The body is accepted but ignored; skip validation. SkipValidateBody: true, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented}, - }, ScopeFeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) { + }, scope.FeatureSettings), func(ctx context.Context, in *patchSettingsInput) (*settingsOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err diff --git a/hscontrol/api/v2/users.go b/hscontrol/api/v2/users.go index c3d94526..2890f432 100644 --- a/hscontrol/api/v2/users.go +++ b/hscontrol/api/v2/users.go @@ -7,6 +7,7 @@ import ( "time" "github.com/danielgtaylor/huma/v2" + "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types" ) @@ -73,7 +74,7 @@ func registerUsers(api huma.API, b Backend) { Tags: usersTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeUsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) { + }, scope.UsersRead), func(ctx context.Context, in *userByIDInput) (*userOutput, error) { view, err := lookupUser(b, in.UserID) if err != nil { return nil, err @@ -90,7 +91,7 @@ func registerUsers(api huma.API, b Backend) { Tags: usersTags, Security: security, Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, - }, ScopeUsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { + }, scope.UsersRead), func(ctx context.Context, in *listUsersInput) (*listUsersOutput, error) { err := requireDefaultTailnet(in.Tailnet) if err != nil { return nil, err diff --git a/hscontrol/apiv2_keys_test.go b/hscontrol/apiv2_keys_test.go index c71b18cf..5a296b2d 100644 --- a/hscontrol/apiv2_keys_test.go +++ b/hscontrol/apiv2_keys_test.go @@ -17,8 +17,8 @@ import ( ) // taggedCaps builds capabilities for a tagged auth key. -func taggedCaps(tags ...string) apiv2.KeyCapabilities { - return apiv2.KeyCapabilities{ +func taggedCaps(tags ...string) *apiv2.KeyCapabilities { + return &apiv2.KeyCapabilities{ Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ Reusable: true, Preauthorized: true, @@ -138,7 +138,7 @@ func TestAPIv2Key_Create_Tagged(t *testing.T) { KeyType: "auth", Description: "dev access", ExpirySeconds: 86400, - Capabilities: taggedCaps("tag:test"), + Capabilities: *taggedCaps("tag:test"), Tags: []string{"tag:test"}, } if diff := cmp.Diff(want, created, cmpopts.IgnoreFields(apiv2.Key{}, "ID", "Key", "Created", "Expires")); diff != "" { @@ -172,7 +172,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) { }{ { name: "single-use", - req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{ + req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{ Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:test"}}}, }}, wantSeconds: 7776000, @@ -185,7 +185,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) { }, { name: "ephemeral", - req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{ + req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{ Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ Ephemeral: true, Tags: []string{"tag:test"}, @@ -246,7 +246,7 @@ func TestAPIv2Key_Get(t *testing.T) { KeyType: "auth", Description: "dev access", ExpirySeconds: 86400, - Capabilities: taggedCaps("tag:test"), + Capabilities: *taggedCaps("tag:test"), Tags: []string{"tag:test"}, } if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(apiv2.Key{}, "Created", "Expires")); diff != "" { @@ -313,7 +313,7 @@ func TestAPIv2Key_Create_NoTags_NoOwner_400(t *testing.T) { before := keyCount(t, app) - resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{}}) + resp := api.Post("/api/v2/tailnet/-/keys", apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{}}) assert.Equal(t, http.StatusBadRequest, resp.Code) assert.Contains(t, resp.Body.String(), `"message"`) diff --git a/hscontrol/scope/scope.go b/hscontrol/scope/scope.go index 6a06917a..1a1a0a6d 100644 --- a/hscontrol/scope/scope.go +++ b/hscontrol/scope/scope.go @@ -40,6 +40,9 @@ const ( FeatureSettings Scope = "feature_settings" FeatureSettingsRead Scope = "feature_settings:read" + + Users Scope = "users" + UsersRead Scope = "users:read" ) const readSuffix = ":read" @@ -55,6 +58,7 @@ func Known() []Scope { DevicesRoutes, DevicesRoutesRead, PolicyFile, PolicyFileRead, FeatureSettings, FeatureSettingsRead, + Users, UsersRead, } } @@ -69,7 +73,7 @@ func (s Scope) IsWrite() bool { } // Parse converts scope strings (as stored on a token or client) into Scope values. -// Unknown strings are kept verbatim; they simply never satisfy any required scope. +// Unknown strings are kept as-is; they simply never satisfy any required scope. func Parse(ss []string) []Scope { out := make([]Scope, len(ss)) for i, s := range ss {