api/v2: add OAuth client-credentials auth and scope enforcement

This commit is contained in:
Kristoffer Dalby 2026-06-21 17:35:32 +00:00
parent ee95cf58d9
commit 84715e976c
12 changed files with 708 additions and 156 deletions

View file

@ -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 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 Tailscale ecosystem that cannot talk to Headscale today works: the
[Terraform/OpenTofu provider], [tscli], and the official [Go client] [Terraform/OpenTofu provider], [tscli], and the official [Go client]
(`tailscale.com/client/tailscale/v2`). (`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 - Errors use **Tailscale's** body (`{"message","data","status"}`), installed as
a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future a per-API transform (`tailscaleErrorTransformer` in `errors.go`). A future
headscale-native v2 operation would keep Huma's RFC 9457 problem+json. 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 - Auth accepts a credential as **HTTP Basic** (key as username, what the SDK
sends) or **Bearer**. See `authMiddleware`. sends) or **Bearer**: an admin API key (`hskey-api-…`), or an OAuth access
- Each operation declares the Tailscale scope it would require (`auth_keys`, token (`hskey-oauthtok-…`). See `authMiddleware`.
`devices:core`, `devices:routes`, `policy_file`, `feature_settings`, each with - Each operation declares the Tailscale scope it requires (`auth_keys`,
a `:read` subset). Nothing is enforced yet — every key is all-access — pending `oauth_keys`, `devices:core`, `devices:routes`, `policy_file`,
OAuth tokens; see the `TODO(scopes)` in `api.go`. `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`, - Resolve one entity by id with a typed getter (`GetNodeByID`, `GetUserByID`,
`GetAPIKeyByID`, `GetPreAuthKeyByID`); add one to state/db if it is missing `GetAPIKeyByID`, `GetPreAuthKeyByID`); add one to state/db if it is missing
rather than scanning a `List`. Build responses from the view accessors 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 `ExpirySeconds *time.Duration` marshals as nanoseconds, which the spec and
every client read as seconds. 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-<clientID>-<secret>`, 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 ## Adding an endpoint
Worked example: the keys resource (`keys.go`) = Tailscale auth keys = Headscale 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 3. **Map to Headscale.** Write the field ↔ field ↔ `state` call mapping. Record
gaps and the decision for each (e.g. Tailscale `preauthorized` has no 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 request field is consumed or deliberately ignored; every response field has a
source._ 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` 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 (`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 loopback port (`servertest.WithRealListener`). tscli and tofu come from the
nix dev shell; a missing binary fails the test. _Acceptance: `nix develop -c nix dev shell; a missing binary fails the test. _Acceptance: `nix develop -c
go test ./hscontrol/servertest/ -run TestAPIv2` is green._ go test ./hscontrol/servertest/ -run TestAPIv2` is green._
7. **Update the CLI** only if the v2 operation fully replaces a v1 one. Tailscale 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` `DELETE` to a soft revoke: the key stays retrievable with `invalid: true`
until the collector reaps it (`preauth_keys.revoked_retention`), the until the collector reaps it (`preauth_keys.revoked_retention`), the
equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on equivalent of v1 `preauthkeys expire`. `headscale preauthkeys` still stays on

View file

@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util"
) )
@ -60,7 +61,7 @@ func registerACL(api huma.API, b Backend) {
Tags: aclTags, Tags: aclTags,
Security: security, Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusInternalServerError}, 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) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err return nil, err
@ -89,7 +90,7 @@ func registerACL(api huma.API, b Backend) {
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError, 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) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -1,8 +1,8 @@
// Package apiv2 is Headscale's v2 HTTP API, served at /api/v2. // 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 // Where the v1 API (hscontrol/api/v1) is the headscale-native admin surface, v2
// additionally ports selected endpoints from Tailscale's API reusing // additionally ports selected endpoints from Tailscale's API, reusing
// Tailscale's wire shapes (paths, request/response JSON, error body) so the // Tailscale's wire shapes (paths, request/response JSON, error body), so the
// existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and // existing Tailscale ecosystem (the Terraform/OpenTofu provider, tscli, and
// tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported // tailscale.com/client/tailscale/v2) can drive Headscale unchanged. Ported
// operations carry the "Tailscale compat" tag; a headscale-native v2 operation // 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"
"github.com/danielgtaylor/huma/v2/adapters/humachi" "github.com/danielgtaylor/huma/v2/adapters/humachi"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/state" "github.com/juanfont/headscale/hscontrol/state"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/types/change" "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 // 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. // or as a Bearer token.
var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}} 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 // Accept application/hujson request bodies (the Tailscale SDK sends the
// policy file that way). The bytes are captured raw by the ACL handler, so // 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. // 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 := maps.Clone(config.Formats)
formats["application/hujson"] = formats["application/json"] formats["application/hujson"] = formats["application/json"]
config.Formats = formats config.Formats = formats
@ -98,6 +99,10 @@ func NewAPI(router chi.Router, backend Backend) huma.API {
Register(api, backend) 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 return api
} }
@ -117,11 +122,21 @@ func Spec() ([]byte, error) {
return api.OpenAPI().YAML() 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 type contextKey int
const ( const (
localTrustKey contextKey = iota localTrustKey contextKey = iota
ownerUserKey ownerUserKey
principalScopesKey
principalTagsKey
) )
// WithLocalTrust marks a request as arriving over a locally-trusted transport // 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 // authMiddleware authenticates the API key (HTTP Basic with the key as the
// username — what the Tailscale SDK sends — or Bearer), records the key's // username, what the Tailscale SDK sends, or Bearer), records the key's owning
// owning user for handlers, and enforces the operation's required scope. // user for handlers, and enforces the operation's required scope.
func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) { func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Context)) {
return func(ctx huma.Context, next func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) {
if ctx.Context().Value(localTrustKey) != nil { if ctx.Context().Value(localTrustKey) != nil {
@ -159,6 +174,35 @@ func authMiddleware(api huma.API, b Backend) func(huma.Context, func(huma.Contex
return 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) key, err := b.State.AuthenticateAPIKey(token)
if err != nil { if err != nil {
_ = huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized") _ = 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 return
} }
// TODO(scopes): every valid key is all-access today. Operations still // An admin API key is all-access: its operations are not scope-checked.
// declare their required scope (requireScope) so that once OAuth tokens // Record its owning user (may be unset) so handlers can create user-owned
// arrive, the granted set can be derived from the token and checked // keys on its behalf.
// 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.
if key.UserID != nil { if key.UserID != nil {
ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID)) ctx = huma.WithValue(ctx, ownerUserKey, types.UserID(*key.UserID))
} }
@ -210,6 +250,24 @@ func ownerUser(ctx context.Context) (types.UserID, bool) {
return uid, ok 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 // requireDefaultTailnet rejects any tailnet other than "-". Headscale is
// single-tailnet; the Tailscale SDK sends "-" (its default tailnet). A non-"-" // 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. // 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 return nil
} }
// Scope is an OAuth capability an operation requires and a token grants. The // The scope vocabulary and the grant predicate live in the hscontrol/scope
// names mirror Tailscale's API scopes (see the OAuth scope descriptions in the // package; this file only wires a required scope onto each huma operation and
// Tailscale OpenAPI spec); a ...Read scope is the read-only subset of its // reads it back in the middleware.
// 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
const ( // scopeMetaKey keys the per-operation required scope in huma.Operation.Metadata.
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.
const scopeMetaKey = "headscale.scope" const scopeMetaKey = "headscale.scope"
// requireScope records op's required scope in its Metadata, where the auth // requireScope records op's required scope, both in its Metadata (where the auth
// middleware can read it back. It keeps the requirement next to the operation // middleware reads it back) and in the generated OpenAPI document: an
// definition. // x-required-scope extension for machine consumers and a Description line so the
func requireScope(op huma.Operation, s Scope) huma.Operation { // rendered docs state what each operation needs.
func requireScope(op huma.Operation, s scope.Scope) huma.Operation {
if op.Metadata == nil { if op.Metadata == nil {
op.Metadata = map[string]any{} op.Metadata = map[string]any{}
} }
op.Metadata[scopeMetaKey] = s 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 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
}

View file

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util"
"tailscale.com/net/tsaddr" "tailscale.com/net/tsaddr"
@ -125,7 +126,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags, Tags: deviceTags,
Security: security, Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) node, err := lookupNode(b, in.DeviceID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -142,7 +143,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags, Tags: deviceTags,
Security: security, Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err return nil, err
@ -170,7 +171,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security, Security: security,
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) node, err := lookupNode(b, in.DeviceID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -195,7 +196,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security, Security: security,
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) _, err := lookupNode(b, in.DeviceID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -222,7 +223,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security, Security: security,
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) node, err := lookupNode(b, in.DeviceID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -247,7 +248,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security, Security: security,
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) node, err := lookupNode(b, in.DeviceID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -261,6 +262,19 @@ func registerDevices(api huma.API, b Backend) {
return &emptyOutput{}, nil 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) _, nodeChange, err := b.State.SetNodeTags(node.ID(), in.Body.Tags)
if err != nil { if err != nil {
return nil, mapError("setting device tags", err) return nil, mapError("setting device tags", err)
@ -280,7 +294,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security, Security: security,
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) node, err := lookupNode(b, in.DeviceID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -313,7 +327,7 @@ func registerDevices(api huma.API, b Backend) {
Security: security, Security: security,
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) node, err := lookupNode(b, in.DeviceID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -342,7 +356,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags, Tags: deviceTags,
Security: security, Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) node, err := lookupNode(b, in.DeviceID)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -9,12 +9,12 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// apiError is the Tailscale API error body. The official Tailscale Go client // 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 // (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 // 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 // them with an empty message, so tailscaleErrorTransformer rewrites every v2
// tailscaleErrorTransformer. The HTTP status is read from the response code, // error into this shape. The HTTP status is read from the response code, not
// not from the body's status field. // from the body's status field.
type apiError struct { type apiError struct {
Message string `json:"message"` Message string `json:"message"`
Data []apiErrorData `json:"data,omitempty"` Data []apiErrorData `json:"data,omitempty"`

View file

@ -3,10 +3,13 @@ package apiv2
import ( import (
"context" "context"
"net/http" "net/http"
"strconv"
"time" "time"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
) )
func init() { func init() {
@ -17,9 +20,13 @@ func init() {
// 90 days, matching Tailscale and the Terraform provider default. // 90 days, matching Tailscale and the Terraform provider default.
const defaultExpiry = 90 * 24 * time.Hour const defaultExpiry = 90 * 24 * time.Hour
// keyTypeAuth is the only key kind Headscale issues; Tailscale's OAuth-client const (
// and federated-identity kinds are out of scope. // keyTypeAuth is a machine auth key (Headscale pre-auth key); the default.
const keyTypeAuth = "auth" 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 // KeyCapabilities maps a resource to the actions a key permits. Headscale
// populates only devices.create (auth keys); the named types (vs Tailscale's // populates only devices.create (auth keys); the named types (vs Tailscale's
@ -42,16 +49,27 @@ type KeyDeviceCreateCapabilities struct {
Tags []string `json:"tags"` Tags []string `json:"tags"`
} }
// CreateKeyRequest is the POST body. expirySeconds is plain seconds (unlike the // CreateKeyRequest is the POST body. It is multiplexed by keyType: "auth"
// response, see Key). // (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 { type CreateKeyRequest struct {
Capabilities KeyCapabilities `json:"capabilities"` KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"`
ExpirySeconds int64 `doc:"Lifetime in seconds; defaults to 90 days." json:"expirySeconds,omitempty"` // Capabilities is optional: an auth key carries device-create capabilities,
Description string `json:"description,omitempty" maxLength:"50"` // 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 // Key is the Tailscale key response, shared by auth keys and OAuth clients.
// to match the Tailscale spec; the secret key is present only at creation. // expirySeconds is emitted in seconds to match the Tailscale spec; the secret
// key is present only at creation.
type Key struct { type Key struct {
ID string `json:"id"` ID string `json:"id"`
KeyType string `json:"keyType"` KeyType string `json:"keyType"`
@ -63,6 +81,7 @@ type Key struct {
Revoked *time.Time `json:"revoked,omitempty"` Revoked *time.Time `json:"revoked,omitempty"`
Invalid bool `json:"invalid"` Invalid bool `json:"invalid"`
Capabilities KeyCapabilities `json:"capabilities"` Capabilities KeyCapabilities `json:"capabilities"`
Scopes []string `json:"scopes,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
UserID string `json:"userId,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) { func registerKeys(api huma.API, b Backend) {
keysTags := []string{"Keys", "Tailscale compat"} keysTags := []string{"Keys", "Tailscale compat"}
huma.Register(api, requireScope(huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "createKey", OperationID: "createKey",
Method: http.MethodPost, Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/keys", 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, Tags: keysTags,
Security: security, Security: security,
Errors: []int{ Errors: []int{
@ -114,63 +138,25 @@ func registerKeys(api huma.API, b Backend) {
http.StatusForbidden, http.StatusForbidden,
http.StatusNotFound, http.StatusNotFound,
}, },
}, ScopeAuthKeys), func(ctx context.Context, in *createKeyInput) (*keyOutput, error) { }, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
create := in.Body.Capabilities.Devices.Create if in.Body.KeyType == keyTypeClient {
return createOAuthClient(ctx, b, in.Body)
// 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
} }
expiration := time.Now().Add(expiryDuration(in.Body.ExpirySeconds)) return createAuthKey(ctx, b, in.Body)
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
}) })
huma.Register(api, requireScope(huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "listKeys", OperationID: "listKeys",
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys", 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, Tags: keysTags,
Security: security, Security: security,
Errors: []int{ Errors: []int{
@ -178,32 +164,49 @@ func registerKeys(api huma.API, b Backend) {
http.StatusForbidden, http.StatusForbidden,
http.StatusNotFound, http.StatusNotFound,
}, },
}, ScopeAuthKeysRead), func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) { }, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
err := requireDefaultTailnet(in.Tailnet) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err return nil, err
} }
keys, err := b.State.ListPreAuthKeys() scopes, isOAuth := principalScopes(ctx)
if err != nil {
return nil, huma.Error500InternalServerError("listing auth keys", err)
}
out := &listKeysOutput{} out := &listKeysOutput{}
out.Body.Keys = make([]Key, 0, len(keys)) out.Body.Keys = []Key{}
for i := range keys { // A token sees the key kinds it has read scope for; an admin key sees all.
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) 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 return out, nil
}) })
huma.Register(api, requireScope(huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getKey", OperationID: "getKey",
Method: http.MethodGet, Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", 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, Tags: keysTags,
Security: security, Security: security,
Errors: []int{ Errors: []int{
@ -211,12 +214,29 @@ func registerKeys(api huma.API, b Backend) {
http.StatusForbidden, http.StatusForbidden,
http.StatusNotFound, http.StatusNotFound,
}, },
}, ScopeAuthKeysRead), func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) { }, func(ctx context.Context, in *keyByIDInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err 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) key, err := findKeyByID(b, in.KeyID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -225,11 +245,12 @@ func registerKeys(api huma.API, b Backend) {
return &keyOutput{Body: keyFromStored(key)}, nil return &keyOutput{Body: keyFromStored(key)}, nil
}) })
huma.Register(api, requireScope(huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "deleteKey", OperationID: "deleteKey",
Method: http.MethodDelete, Method: http.MethodDelete,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}", 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, Tags: keysTags,
Security: security, Security: security,
DefaultStatus: http.StatusOK, DefaultStatus: http.StatusOK,
@ -238,12 +259,31 @@ func registerKeys(api huma.API, b Backend) {
http.StatusForbidden, http.StatusForbidden,
http.StatusNotFound, http.StatusNotFound,
}, },
}, ScopeAuthKeys), func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) { }, func(ctx context.Context, in *keyByIDInput) (*deleteKeyOutput, error) {
err := requireDefaultTailnet(in.Tailnet) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err 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") id, err := parseID(in.KeyID, "auth key")
if err != nil { if err != nil {
return nil, err 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 // 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 // direct by-id query; an unknown id surfaces as gorm.ErrRecordNotFound, which
// mapError turns into a 404. // 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 // match keyFromStored: Headscale always authorizes pre-auth-key nodes, so the
// create and read paths must agree or the Terraform provider sees a diff. // create and read paths must agree or the Terraform provider sees a diff.
func keyFromNew(pak *types.PreAuthKeyNew, req CreateKeyRequest) Key { 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{ key := Key{
ID: pak.StringID(), ID: pak.StringID(),
@ -344,6 +544,32 @@ func keyFromStored(pak *types.PreAuthKey) Key {
return 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( func capabilities(
reusable, ephemeral, preauthorized bool, reusable, ephemeral, preauthorized bool,
tags []string, tags []string,

178
hscontrol/api/v2/oauth.go Normal file
View file

@ -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,
})
}

View file

@ -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)
}

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
) )
@ -59,7 +60,7 @@ func registerSettings(api huma.API, b Backend) {
Tags: settingsTags, Tags: settingsTags,
Security: security, Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err return nil, err
@ -86,7 +87,7 @@ func registerSettings(api huma.API, b Backend) {
// The body is accepted but ignored; skip validation. // The body is accepted but ignored; skip validation.
SkipValidateBody: true, SkipValidateBody: true,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusNotImplemented}, 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) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
) )
@ -73,7 +74,7 @@ func registerUsers(api huma.API, b Backend) {
Tags: usersTags, Tags: usersTags,
Security: security, Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) view, err := lookupUser(b, in.UserID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -90,7 +91,7 @@ func registerUsers(api huma.API, b Backend) {
Tags: usersTags, Tags: usersTags,
Security: security, Security: security,
Errors: []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound}, 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) err := requireDefaultTailnet(in.Tailnet)
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -17,8 +17,8 @@ import (
) )
// taggedCaps builds capabilities for a tagged auth key. // taggedCaps builds capabilities for a tagged auth key.
func taggedCaps(tags ...string) apiv2.KeyCapabilities { func taggedCaps(tags ...string) *apiv2.KeyCapabilities {
return apiv2.KeyCapabilities{ return &apiv2.KeyCapabilities{
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{
Reusable: true, Reusable: true,
Preauthorized: true, Preauthorized: true,
@ -138,7 +138,7 @@ func TestAPIv2Key_Create_Tagged(t *testing.T) {
KeyType: "auth", KeyType: "auth",
Description: "dev access", Description: "dev access",
ExpirySeconds: 86400, ExpirySeconds: 86400,
Capabilities: taggedCaps("tag:test"), Capabilities: *taggedCaps("tag:test"),
Tags: []string{"tag:test"}, Tags: []string{"tag:test"},
} }
if diff := cmp.Diff(want, created, cmpopts.IgnoreFields(apiv2.Key{}, "ID", "Key", "Created", "Expires")); diff != "" { 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", 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"}}}, Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{Tags: []string{"tag:test"}}},
}}, }},
wantSeconds: 7776000, wantSeconds: 7776000,
@ -185,7 +185,7 @@ func TestAPIv2Key_Create_Permutations(t *testing.T) {
}, },
{ {
name: "ephemeral", name: "ephemeral",
req: apiv2.CreateKeyRequest{Capabilities: apiv2.KeyCapabilities{ req: apiv2.CreateKeyRequest{Capabilities: &apiv2.KeyCapabilities{
Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{ Devices: apiv2.KeyDeviceCapabilities{Create: apiv2.KeyDeviceCreateCapabilities{
Ephemeral: true, Ephemeral: true,
Tags: []string{"tag:test"}, Tags: []string{"tag:test"},
@ -246,7 +246,7 @@ func TestAPIv2Key_Get(t *testing.T) {
KeyType: "auth", KeyType: "auth",
Description: "dev access", Description: "dev access",
ExpirySeconds: 86400, ExpirySeconds: 86400,
Capabilities: taggedCaps("tag:test"), Capabilities: *taggedCaps("tag:test"),
Tags: []string{"tag:test"}, Tags: []string{"tag:test"},
} }
if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(apiv2.Key{}, "Created", "Expires")); diff != "" { 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) 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.Equal(t, http.StatusBadRequest, resp.Code)
assert.Contains(t, resp.Body.String(), `"message"`) assert.Contains(t, resp.Body.String(), `"message"`)

View file

@ -40,6 +40,9 @@ const (
FeatureSettings Scope = "feature_settings" FeatureSettings Scope = "feature_settings"
FeatureSettingsRead Scope = "feature_settings:read" FeatureSettingsRead Scope = "feature_settings:read"
Users Scope = "users"
UsersRead Scope = "users:read"
) )
const readSuffix = ":read" const readSuffix = ":read"
@ -55,6 +58,7 @@ func Known() []Scope {
DevicesRoutes, DevicesRoutesRead, DevicesRoutes, DevicesRoutesRead,
PolicyFile, PolicyFileRead, PolicyFile, PolicyFileRead,
FeatureSettings, FeatureSettingsRead, 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. // 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 { func Parse(ss []string) []Scope {
out := make([]Scope, len(ss)) out := make([]Scope, len(ss))
for i, s := range ss { for i, s := range ss {