mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
api/v2: add OAuth client-credentials auth and scope enforcement
This commit is contained in:
+48
-11
@@ -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-<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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+104
-45
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
+298
-72
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user