Compare commits

...

6 Commits

Author SHA1 Message Date
Kristoffer Dalby 8b17c26d11 oidc: harden callback CSRF cookies, state reuse, and issuer config
Defence-in-depth fixes to the OIDC login flow.

Set the state/nonce cookie Secure flag from the configured server_url
scheme rather than req.TLS, so the cookies stay Secure behind a
TLS-terminating reverse proxy where the proxy-to-Headscale hop is plain
HTTP. Deriving it from config avoids trusting a spoofable
X-Forwarded-Proto header.

Make the OIDC state single-use: consume it from the cache on the
callback and clear the state/nonce cookies once validated, so a replayed
callback cannot resolve the same session and the cookies do not linger
until expiry.

Bound OIDC discovery to the caller's context so a slow or unreachable
issuer fails startup within the timeout instead of hanging, and validate
the issuer URL and required client_id/client_secret at config load so an
unworkable setup fails fast.
2026-06-24 19:34:03 +00:00
Kristoffer Dalby 3fca03ad61 changelog: note OAuth clients and scopes 2026-06-24 19:34:03 +00:00
Kristoffer Dalby 90bffbebdd hscontrol, integration: test OAuth scope enforcement and the oauth-clients CLI
Add scope-enforcement coverage and integration tests for the
oauth-clients CLI across the test matrix.
2026-06-24 19:34:03 +00:00
Kristoffer Dalby 84bb7c3794 hscontrol, cli: serve the v2 API on the local socket and add the oauth-clients command
Serve the v2 API over the local unix socket for the CLI and add the
oauth-clients command that drives it.
2026-06-24 19:34:03 +00:00
Kristoffer Dalby 87555ce621 api/v2: add OAuth client-credentials auth and scope enforcement 2026-06-24 17:28:31 +00:00
Kristoffer Dalby 3fdc068c8c hscontrol: add the OAuth client and access-token model
Add the OAuth client type, its database storage, the scope grant package,
policy tag-ownership exposure, and the state operations backing the v2
OAuth client-credentials flow.
2026-06-24 17:28:31 +00:00
40 changed files with 8164 additions and 210 deletions
+2
View File
@@ -275,6 +275,8 @@ jobs:
- TestNodeTagCommand
- TestNodeRouteCommands
- TestNodeBackfillIPsCommand
- TestOAuthClientCommand
- TestOAuthClientCommandValidation
- TestPolicyCheckCommand
- TestSSHTestsRejectFailingPolicy
- TestPolicyCommand
+12
View File
@@ -14,6 +14,18 @@ HTTP API directly.
[#3324](https://github.com/juanfont/headscale/pull/3324)
### OAuth clients and scopes for the v2 API
The v2 API now authenticates with OAuth 2.0 client-credentials, the way the
Tailscale ecosystem does. An OAuth client mints short-lived access tokens whose
scopes limit which operations they may perform and whose tags limit the devices
they may create, so a credential can be issued with only the access it needs.
The `headscale oauth-clients` command manages them. This lets the Tailscale
Terraform provider and Kubernetes operator drive Headscale unchanged; admin API
keys remain all-access.
[#3334](https://github.com/juanfont/headscale/pull/3334)
### BREAKING
#### API
+9 -6
View File
@@ -91,17 +91,20 @@ openapi:
@echo "Emitting OpenAPI spec from code..."
go run ./cmd/gen-openapi
# Generate the strongly-typed Go HTTP client. The served spec is OpenAPI 3.1,
# but oapi-codegen v2 does not yet read 3.1, so the client is generated from a
# transient 3.0.3 downgrade of the same document. Pinned so the committed client
# is reproducible.
# Generate the strongly-typed Go HTTP clients (v1 and v2). The served specs are
# OpenAPI 3.1, but oapi-codegen v2 does not yet read 3.1, so each client is
# generated from a transient 3.0.3 downgrade of its document. Pinned so the
# committed clients are reproducible.
.PHONY: client
client:
@echo "Generating API client..."
@echo "Generating API clients..."
@tmp=$$(mktemp -t headscale-openapi-3.0.XXXXXX.yaml); \
go run ./cmd/gen-openapi -downgrade "$$tmp" && \
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \
-generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp"; \
-generate types,client -package clientv1 -o gen/client/v1/client.gen.go "$$tmp" && \
go run ./cmd/gen-openapi -api v2 -downgrade "$$tmp" && \
go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.7.1 \
-generate types,client -package clientv2 -o gen/client/v2/client.gen.go "$$tmp"; \
status=$$?; rm -f "$$tmp"; exit $$status
# Clean targets
+36 -11
View File
@@ -1,33 +1,58 @@
// Command gen-openapi emits the Headscale v1 OpenAPI document from the
// authoritative Huma definitions in hscontrol/api/v1. The server also serves the
// spec live at /openapi.yaml; this tool emits it on demand, and with -downgrade
// the 3.0.3 form used to generate the client. The output is not committed.
// Command gen-openapi emits a Headscale OpenAPI document from the authoritative
// Huma definitions in hscontrol/api/v1 and hscontrol/api/v2. The server also
// serves each spec live (at /openapi.yaml and /api/v2/openapi); this tool emits
// them on demand, and with -downgrade the 3.0.3 form used to generate the typed
// client. The output is not committed.
//
// Usage:
//
// go run ./cmd/gen-openapi # write the 3.1 spec to openapi/v1/headscale.yaml
// go run ./cmd/gen-openapi -downgrade <path> # write the 3.0.3 downgrade (for client gen)
// go run ./cmd/gen-openapi # write the v1 3.1 spec to its default path
// go run ./cmd/gen-openapi -api v2 # write the v2 3.1 spec to its default path
// go run ./cmd/gen-openapi -downgrade <path> # write the v1 3.0.3 downgrade (for client gen)
// go run ./cmd/gen-openapi -api v2 -downgrade <path> # the v2 3.0.3 downgrade
package main
import (
"flag"
"log"
"os"
"path/filepath"
apiv1 "github.com/juanfont/headscale/hscontrol/api/v1"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
)
// outPath is relative to the repository root.
const outPath = "openapi/v1/headscale.yaml"
// spec bundles a version's full (3.1) and downgraded (3.0.3) generators with the
// committed output path. outPath is relative to the repository root.
type spec struct {
full func() ([]byte, error)
down func() ([]byte, error)
outPath string
}
// specs maps the -api value to its generators.
var specs = map[string]spec{
"v1": {apiv1.Spec, apiv1.Spec30, "openapi/v1/headscale.yaml"},
"v2": {apiv2.Spec, apiv2.Spec30, "openapi/v2/headscale.yaml"},
}
func main() {
if len(os.Args) == 3 && os.Args[1] == "-downgrade" {
writeSpec(os.Args[2], apiv1.Spec30)
api := flag.String("api", "v1", "which API spec to emit: v1 or v2")
downgrade := flag.String("downgrade", "", "write the OpenAPI 3.0.3 downgrade to this path instead of the committed 3.1 spec")
flag.Parse()
s, ok := specs[*api]
if !ok {
log.Fatalf("unknown -api %q (want v1 or v2)", *api)
}
if *downgrade != "" {
writeSpec(*downgrade, s.down)
return
}
writeSpec(outPath, apiv1.Spec)
writeSpec(s.outPath, s.full)
}
func writeSpec(path string, gen func() ([]byte, error)) {
+271
View File
@@ -0,0 +1,271 @@
package cli
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
clientv2 "github.com/juanfont/headscale/gen/client/v2"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/spf13/cobra"
)
// oauthTailnet is the single Headscale tailnet the v2 API addresses as "-".
const oauthTailnet = "-"
func init() {
rootCmd.AddCommand(oauthClientsCmd)
oauthClientsCmd.AddCommand(listOAuthClientsCmd)
createOAuthClientCmd.Flags().
StringArrayP("scope", "s", nil, "Scope the client's tokens are granted (repeatable): auth_keys, oauth_keys, devices:core, devices:routes, policy_file, feature_settings (each with a :read variant), or all/all:read")
createOAuthClientCmd.Flags().
StringArrayP("tag", "t", nil, "Tag the client's tokens may assign to devices (repeatable), e.g. tag:k8s-operator")
createOAuthClientCmd.Flags().StringP("description", "d", "", "Human-readable description")
oauthClientsCmd.AddCommand(createOAuthClientCmd)
deleteOAuthClientCmd.Flags().StringP("id", "i", "", "OAuth client id")
oauthClientsCmd.AddCommand(deleteOAuthClientCmd)
}
var oauthClientsCmd = &cobra.Command{
Use: "oauth-clients",
Short: "Manage OAuth clients",
Aliases: []string{"oauth-client", "oauthclients", "oauthclient", "oauth"},
}
var createOAuthClientCmd = &cobra.Command{
Use: "create",
Short: "Create an OAuth client",
Long: `Create a general-purpose OAuth client. It authenticates with the OAuth 2.0
client-credentials grant and mints short-lived, scope-limited access tokens.
The wire format is compatible with Tailscale tooling (the Terraform provider,
the Kubernetes operator, tscli, ...), so those can drive Headscale unchanged.
The client secret is shown ONCE on creation and cannot be retrieved again; if
you lose it, delete the client and create a new one.
Scopes gate what the client's tokens may do; tags are the device tags those
tokens may assign and are required when the scopes include devices:core or
auth_keys.`,
Aliases: []string{"c", cmdNew},
RunE: func(cmd *cobra.Command, _ []string) error {
scopes, _ := cmd.Flags().GetStringArray("scope")
tags, _ := cmd.Flags().GetStringArray("tag")
description, _ := cmd.Flags().GetString("description")
if len(scopes) == 0 {
return fmt.Errorf("at least one --scope is required: %w", errMissingParameter)
}
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
keyType := "client"
resp, err := client.CreateKeyWithResponse(ctx, oauthTailnet, clientv2.CreateKeyRequest{
KeyType: &keyType,
Scopes: &scopes,
Tags: &tags,
Description: &description,
})
if err != nil {
return fmt.Errorf("creating oauth client: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
key := resp.JSON200
return printOutput(cmd, key,
fmt.Sprintf("OAuth client %s created.\nSecret (shown once, store it now): %s", key.Id, ptrStr(key.Key)))
},
}
var listOAuthClientsCmd = &cobra.Command{
Use: cmdList,
Short: "List OAuth clients",
Aliases: []string{"ls", cmdShow},
RunE: func(cmd *cobra.Command, _ []string) error {
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
resp, err := client.ListKeysWithResponse(ctx, oauthTailnet, nil)
if err != nil {
return fmt.Errorf("listing oauth clients: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
// The keys endpoint is multiplexed; keep only OAuth clients.
clients := make([]clientv2.Key, 0, len(resp.JSON200.Keys))
for _, k := range resp.JSON200.Keys {
if k.KeyType == "client" {
clients = append(clients, k)
}
}
return printListOutput(cmd, clients, func() error {
rows := make([][]string, 0, len(clients))
for _, c := range clients {
rows = append(rows, []string{
c.Id,
strings.Join(ptrStrs(c.Scopes), ","),
strings.Join(ptrStrs(c.Tags), ","),
ptrStr(c.Description),
c.Created.Format(HeadscaleDateTimeFormat),
})
}
return renderTable([]string{"ID", "Scopes", "Tags", "Description", colCreated}, rows)
})
},
}
var deleteOAuthClientCmd = &cobra.Command{
Use: cmdDelete,
Short: "Delete an OAuth client",
Aliases: []string{"remove", aliasDel},
RunE: func(cmd *cobra.Command, _ []string) error {
id, _ := cmd.Flags().GetString("id")
if id == "" {
return fmt.Errorf("--id is required: %w", errMissingParameter)
}
ctx, client, cancel, err := newV2Client()
if err != nil {
return err
}
defer cancel()
resp, err := client.DeleteKeyWithResponse(ctx, oauthTailnet, id)
if err != nil {
return fmt.Errorf("deleting oauth client: %w", err)
}
err = v2Error(resp.HTTPResponse.StatusCode, resp.Body)
if err != nil {
return err
}
return printOutput(cmd, map[string]string{"id": id}, "OAuth client "+id+" deleted")
},
}
// newV2Client builds a generated v2 API client, selecting the transport the same
// way the v1 client does: over the local unix socket it is unauthenticated
// (local trust); a remote address injects the configured API key as a bearer
// token.
func newV2Client() (context.Context, *clientv2.ClientWithResponses, context.CancelFunc, error) {
cfg, err := types.LoadCLIConfig()
if err != nil {
return nil, nil, nil, fmt.Errorf("loading configuration: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), cfg.CLI.Timeout)
if cfg.CLI.Address == "" {
socketPath := cfg.UnixSocket
httpClient := &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialHeadscaleSocket(ctx, socketPath)
},
}}
client, err := clientv2.NewClientWithResponses("http://local", clientv2.WithHTTPClient(httpClient))
if err != nil {
cancel()
return nil, nil, nil, err
}
return ctx, client, cancel, nil
}
if cfg.CLI.APIKey == "" {
cancel()
return nil, nil, nil, errAPIKeyNotSet
}
transport := &http.Transport{}
if cfg.CLI.Insecure {
//nolint:gosec // intentionally honouring the insecure flag
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
apiKey := cfg.CLI.APIKey
client, err := clientv2.NewClientWithResponses(
clientBaseURL(cfg.CLI.Address),
clientv2.WithHTTPClient(&http.Client{Transport: transport}),
clientv2.WithRequestEditorFn(func(_ context.Context, req *http.Request) error {
req.Header.Set("Authorization", "Bearer "+apiKey)
return nil
}),
)
if err != nil {
cancel()
return nil, nil, nil, err
}
return ctx, client, cancel, nil
}
// v2Error turns a non-2xx v2 response into an error. The v2 API emits the
// Tailscale error body ({"message":...}) rather than RFC 7807, so it reads the
// "message" field instead of the generated problem+json types.
func v2Error(status int, body []byte) error {
if status >= http.StatusOK && status < http.StatusMultipleChoices {
return nil
}
var e struct {
Message string `json:"message"`
}
if json.Unmarshal(body, &e) == nil && e.Message != "" {
//nolint:err113 // surfacing the server's message
return fmt.Errorf("api error (%d): %s", status, e.Message)
}
//nolint:err113 // surfacing the server's body
return fmt.Errorf("api error (%d): %s", status, strings.TrimSpace(string(body)))
}
func ptrStr(s *string) string {
if s == nil {
return ""
}
return *s
}
func ptrStrs(s *[]string) []string {
if s == nil {
return nil
}
return *s
}
File diff suppressed because it is too large Load Diff
+48 -11
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
**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
+3 -2
View File
@@ -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
View File
@@ -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
}
+23 -9
View File
@@ -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
+5 -5
View File
@@ -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"`
+58
View File
@@ -0,0 +1,58 @@
package apiv2
import (
"testing"
"github.com/danielgtaylor/huma/v2"
"github.com/go-chi/chi/v5"
"github.com/juanfont/headscale/hscontrol/scope"
)
// selfEnforcedKeyOps are the authenticated operations that intentionally declare
// NO static scope because they multiplex on keyType and authorize inside the
// handler via requireKeyScope (see keys.go). Every other authenticated operation
// must declare a scope.
var selfEnforcedKeyOps = map[string]bool{
"POST /api/v2/tailnet/{tailnet}/keys": true,
"GET /api/v2/tailnet/{tailnet}/keys": true,
"GET /api/v2/tailnet/{tailnet}/keys/{keyId}": true,
"DELETE /api/v2/tailnet/{tailnet}/keys/{keyId}": true,
}
// TestEveryAuthenticatedOperationDeclaresScope is the structural guarantee that no
// v2 operation ships unprotected: any operation that requires authentication
// (non-empty Security) must either declare a required scope via requireScope, or
// be one of the keyType-multiplexed keys operations that self-enforce. A new
// operation added without scope protection fails this test.
func TestEveryAuthenticatedOperationDeclaresScope(t *testing.T) {
api := NewAPI(chi.NewMux(), Backend{})
for path, item := range api.OpenAPI().Paths {
for method, op := range humaOperations(item) {
if op == nil || len(op.Security) == 0 {
continue // unregistered method or a public operation
}
key := method + " " + path
if selfEnforcedKeyOps[key] {
continue
}
if _, ok := op.Metadata[scopeMetaKey].(scope.Scope); !ok {
t.Errorf("operation %q is authenticated but declares no required scope; "+
"wrap it in requireScope, or add it to selfEnforcedKeyOps if it "+
"authorizes inside the handler", key)
}
}
}
}
func humaOperations(item *huma.PathItem) map[string]*huma.Operation {
return map[string]*huma.Operation{
"GET": item.Get,
"POST": item.Post,
"PUT": item.Put,
"DELETE": item.Delete,
"PATCH": item.Patch,
}
}
+298 -72
View File
@@ -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,
+178
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,
})
}
+31
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)
}
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+7 -7
View File
@@ -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"`)
+280
View File
@@ -0,0 +1,280 @@
package hscontrol
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// apiReq issues an arbitrary-method request with an optional bearer token and JSON
// body, returning the status and raw body. It owns the response body.
func apiReq(t *testing.T, method, target, bearer string, body any) (int, []byte) {
t.Helper()
var r io.Reader
if body != nil {
b, err := json.Marshal(body)
require.NoError(t, err)
r = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(t.Context(), method, target, r)
require.NoError(t, err)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return resp.StatusCode, data
}
// scopeDenied reports whether a response is a scope-enforcement denial. The scope
// gate runs in the middleware before the handler, so a denial is exactly a 403
// carrying this message; any other status (200, or a 400/404 from the handler on
// minimal input) means the request passed the scope gate.
func scopeDenied(status int, body []byte) bool {
return status == http.StatusForbidden &&
strings.Contains(string(body), "missing the required scope")
}
// mintScopedToken creates an OAuth client holding exactly scope s (tagged tag:ci so
// tag-requiring scopes are valid) and returns an access token for it.
func mintScopedToken(t *testing.T, baseURL, admin string, s scope.Scope) string {
t.Helper()
_, secret := createClient(t, baseURL, admin, []string{string(s)}, []string{"tag:ci"})
return tokenFor(t, baseURL, secret)
}
// createAdminAuthKey creates a tagged auth key with the admin key and returns its id.
func createAdminAuthKey(t *testing.T, baseURL, admin string) string {
t.Helper()
status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", admin, apiv2.CreateKeyRequest{
Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{
Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: []string{"tag:ci"}},
}},
})
require.Equalf(t, http.StatusOK, status, "create admin auth key: %s", body)
var key apiv2.Key
require.NoError(t, json.Unmarshal(body, &key))
return key.ID
}
type matrixOp struct {
name string
method string
path string
need scope.Scope
body any
// multiplexed marks the keyType-multiplexed keys ops. They self-enforce in
// the handler and, to avoid an existence oracle, deny an unauthorized token
// with a uniform not-found rather than the middleware's scope-403. So denial
// is "resource not returned" (status != 200), not a specific 403.
multiplexed bool
}
// TestAPIv2OAuthMatrix_Enforcement is the exhaustive operation×scope cross-product:
// for every scope-gated operation and every scope in the vocabulary, a token
// holding exactly that scope is allowed iff scope.Grants permits it (P3) and denied
// otherwise (P2).
func TestAPIv2OAuthMatrix_Enforcement(t *testing.T) {
app, baseURL, admin := newOAuthTestServer(t)
// Stable ids for the keyType-multiplexed get-by-id operations.
clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, nil)
authKeyID := createAdminAuthKey(t, baseURL, admin)
_ = app
ops := []matrixOp{
{"getDevice", http.MethodGet, "/api/v2/device/1", scope.DevicesCoreRead, nil, false},
{"listDevices", http.MethodGet, "/api/v2/tailnet/-/devices", scope.DevicesCoreRead, nil, false},
{"deleteDevice", http.MethodDelete, "/api/v2/device/1", scope.DevicesCore, nil, false},
{"authorizeDevice", http.MethodPost, "/api/v2/device/1/authorized", scope.DevicesCore, map[string]any{}, false},
{"setDeviceName", http.MethodPost, "/api/v2/device/1/name", scope.DevicesCore, map[string]any{}, false},
{"setDeviceTags", http.MethodPost, "/api/v2/device/1/tags", scope.DevicesCore, map[string]any{}, false},
{"setDeviceKey", http.MethodPost, "/api/v2/device/1/key", scope.DevicesCore, map[string]any{}, false},
{"setDeviceRoutes", http.MethodPost, "/api/v2/device/1/routes", scope.DevicesRoutes, map[string]any{}, false},
{"getDeviceRoutes", http.MethodGet, "/api/v2/device/1/routes", scope.DevicesRoutesRead, nil, false},
{"getACL", http.MethodGet, "/api/v2/tailnet/-/acl", scope.PolicyFileRead, nil, false},
{"setACL", http.MethodPost, "/api/v2/tailnet/-/acl", scope.PolicyFile, map[string]any{}, false},
{"getSettings", http.MethodGet, "/api/v2/tailnet/-/settings", scope.FeatureSettingsRead, nil, false},
{"updateSettings", http.MethodPatch, "/api/v2/tailnet/-/settings", scope.FeatureSettings, map[string]any{}, false},
{"getKeyClient", http.MethodGet, "/api/v2/tailnet/-/keys/" + clientID, scope.OAuthKeysRead, nil, true},
{"getKeyAuth", http.MethodGet, "/api/v2/tailnet/-/keys/" + authKeyID, scope.AuthKeysRead, nil, true},
}
// One token per scope, reused across every operation.
tokens := make(map[scope.Scope]string, len(scope.Known()))
for _, s := range scope.Known() {
tokens[s] = mintScopedToken(t, baseURL, admin, s)
}
for _, op := range ops {
for _, s := range scope.Known() {
status, body := apiReq(t, op.method, baseURL+op.path, tokens[s], op.body)
wantDenied := !scope.Grants([]scope.Scope{s}, op.need)
// A multiplexed keys op denies via uniform not-found (no existence
// oracle), so denial is "resource not returned"; every other op
// denies with the middleware's scope-403.
denied := scopeDenied(status, body)
if op.multiplexed {
denied = status != http.StatusOK
}
assert.Equalf(t, wantDenied, denied,
"op %s with scope %q (needs %q): status=%d body=%s",
op.name, s, op.need, status, body)
}
}
}
// TestAPIv2OAuthMatrix_ScopeNarrowing proves P1 for token minting: a client may
// only mint a token for scopes within its own grant. For every held scope X and
// requested scope Y, the mint succeeds iff scope.Grants([X], Y).
func TestAPIv2OAuthMatrix_ScopeNarrowing(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
for _, held := range scope.Known() {
_, secret := createClient(t, baseURL, admin, []string{string(held)}, []string{"tag:ci"})
for _, want := range scope.Known() {
status, m := mintToken(t, baseURL, "", secret, string(want), false)
wantOK := scope.Grants([]scope.Scope{held}, want)
if wantOK {
assert.Equalf(t, http.StatusOK, status, "held %q narrow to %q: %v", held, want, m)
} else {
assert.Equalf(t, http.StatusBadRequest, status,
"held %q must not mint %q: %v", held, want, m)
assert.Equal(t, "invalid_scope", m["error"])
}
}
}
}
// TestAPIv2OAuthMatrix_TagNarrowing proves P1 for tags at mint time: a client may
// only mint a token for tags within its grant (closing the /oauth/token tags-param
// path). A client with the "all" scope may request any tag.
func TestAPIv2OAuthMatrix_TagNarrowing(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
_, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:a", "tag:b"})
// In-grant tags mint; an out-of-grant tag is rejected.
status, m := mintToken(t, baseURL, "", secret, "", false)
require.Equalf(t, http.StatusOK, status, "%v", m)
for _, tag := range []string{"tag:a", "tag:b"} {
st, mm := mintTokenWithTags(t, baseURL, secret, tag)
assert.Equalf(t, http.StatusOK, st, "in-grant tag %q: %v", tag, mm)
}
st, mm := mintTokenWithTags(t, baseURL, secret, "tag:c")
assert.Equal(t, http.StatusBadRequest, st, mm)
assert.Equal(t, "invalid_target", mm["error"])
// An all-scope client may request any tag.
_, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:a"})
stAll, mAll := mintTokenWithTags(t, baseURL, allSecret, "tag:anything")
assert.Equalf(t, http.StatusOK, stAll, "all-scope client may assign any tag: %v", mAll)
}
// TestAPIv2OAuthMatrix_Lifecycle proves expired and revoked credentials are denied
// at the HTTP layer, and that an admin API key bypasses scope checks.
func TestAPIv2OAuthMatrix_Lifecycle(t *testing.T) {
app, baseURL, admin := newOAuthTestServer(t)
devicesPath := baseURL + "/api/v2/tailnet/-/devices"
// Expired token → 401.
_, client, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "expired", nil)
require.NoError(t, err)
past := time.Now().Add(-time.Hour)
expiredTok, _, err := app.state.MintAccessToken(client.ClientID, []string{"devices:core:read"}, nil, &past)
require.NoError(t, err)
status, _ := apiReq(t, http.MethodGet, devicesPath, expiredTok, nil)
assert.Equal(t, http.StatusUnauthorized, status, "expired token must be rejected")
// Revoked client → its token is denied.
_, revClient, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "revoked", nil)
require.NoError(t, err)
future := time.Now().Add(time.Hour)
revTok, _, err := app.state.MintAccessToken(revClient.ClientID, []string{"devices:core:read"}, nil, &future)
require.NoError(t, err)
// Works before revocation.
okStatus, okBody := apiReq(t, http.MethodGet, devicesPath, revTok, nil)
assert.Falsef(t, scopeDenied(okStatus, okBody), "token should pass the scope gate before revoke: %d", okStatus)
require.NoError(t, app.state.RevokeOAuthClient(revClient.ClientID))
revStatus, _ := apiReq(t, http.MethodGet, devicesPath, revTok, nil)
assert.Equal(t, http.StatusUnauthorized, revStatus, "token of a revoked client must be rejected")
// Admin API key bypasses scope checks: it reaches a devices op that a minimal
// (feature_settings:read) token cannot.
minimalTok := mintScopedToken(t, baseURL, admin, scope.FeatureSettingsRead)
minStatus, minBody := apiReq(t, http.MethodGet, devicesPath, minimalTok, nil)
assert.True(t, scopeDenied(minStatus, minBody), "minimal token must be scope-denied on devices")
adminStatus, adminBody := apiReq(t, http.MethodGet, devicesPath, admin, nil)
assert.Falsef(t, scopeDenied(adminStatus, adminBody),
"admin key is all-access and must not be scope-denied: %d %s", adminStatus, adminBody)
}
// mintTokenWithTags mints with a tags narrowing parameter.
func mintTokenWithTags(t *testing.T, baseURL, secret, tags string) (int, map[string]any) {
t.Helper()
form := fmt.Sprintf("grant_type=client_credentials&client_secret=%s&tags=%s", secret, tags)
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
baseURL+"/api/v2/oauth/token", strings.NewReader(form))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(data, &m)
return resp.StatusCode, m
}
+511
View File
@@ -0,0 +1,511 @@
package hscontrol
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newOAuthTestServer builds the full v2 API (the auth+scope middleware and the
// OAuth token endpoint, which the humatest harness in apiv2_keys_test.go does
// not mount) over a real httptest server, returning the app, its base URL, and
// an all-access admin API key.
func newOAuthTestServer(t *testing.T) (*Headscale, string, string) {
t.Helper()
app := createTestApp(t)
// Tag creation now requires the tag to exist in policy (matching
// SetNodeTags), so define the tags these tests assign. Tests needing
// specific tag ownership (e.g. delegation) override this policy.
const policy = `{"tagOwners":{"tag:a":[],"tag:b":[],"tag:c":[],"tag:ci":[],"tag:k8s":[],"tag:k8s-operator":[],"tag:other":[],"tag:anything":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
_, err := app.state.SetPolicy([]byte(policy))
require.NoError(t, err)
_, err = app.state.SetPolicyInDB(policy)
require.NoError(t, err)
_, err = app.state.ReloadPolicy()
require.NoError(t, err)
mux, _ := apiv2.Handler(apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
adminKey, _, err := app.state.CreateAPIKey(nil)
require.NoError(t, err)
return app, srv.URL, adminKey
}
// apiPost POSTs a JSON body with an optional bearer token, returning the status
// and raw body. It owns the response body so callers never leak it.
func apiPost(t *testing.T, target, bearer string, body any) (int, []byte) {
t.Helper()
b, err := json.Marshal(body)
require.NoError(t, err)
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, target, bytes.NewReader(b))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return resp.StatusCode, data
}
// apiGet GETs a target with an optional bearer token, returning the status. It
// drains and closes the response body so callers never leak it.
func apiGet(t *testing.T, target, bearer string) int {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil)
require.NoError(t, err)
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
// createClient creates an OAuth client through the v2 keys API as the bearer
// credential, returning the client id and the once-shown secret.
func createClient(t *testing.T, baseURL, bearer string, scopes, tags []string) (string, string) {
t.Helper()
status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{
KeyType: "client",
Scopes: scopes,
Tags: tags,
})
require.Equalf(t, http.StatusOK, status, "create client: %s", body)
var key apiv2.Key
require.NoError(t, json.Unmarshal(body, &key))
assert.Equal(t, "client", key.KeyType)
require.NotEmpty(t, key.ID, "client id returned")
require.NotEmpty(t, key.Key, "secret returned once on create")
return key.ID, key.Key
}
// mintToken runs the client-credentials grant. scope is an optional space-delimited
// narrowing of the client's scopes. credsInBasic sends the secret via HTTP Basic
// instead of the body. Returns the status and decoded JSON body.
func mintToken(t *testing.T, baseURL, clientID, secret, scope string, credsInBasic bool) (int, map[string]any) {
t.Helper()
form := url.Values{"grant_type": {"client_credentials"}}
if scope != "" {
form.Set("scope", scope)
}
if !credsInBasic {
form.Set("client_secret", secret)
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode()))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if credsInBasic {
req.SetBasicAuth(clientID, secret)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(data, &m)
return resp.StatusCode, m
}
// tokenFor mints and returns a bearer access token for the client.
func tokenFor(t *testing.T, baseURL, secret string) string {
t.Helper()
status, m := mintToken(t, baseURL, "", secret, "", false)
require.Equalf(t, http.StatusOK, status, "mint token: %v", m)
tok, _ := m["access_token"].(string)
require.NotEmpty(t, tok)
return tok
}
// createTaggedKey attempts to create a tagged auth key and returns the HTTP
// status, so allow/deny is asserted by the caller.
func createTaggedKey(t *testing.T, baseURL, bearer string, tags []string) int {
t.Helper()
status, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{
Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{
Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: tags},
}},
})
return status
}
func TestAPIv2OAuth_TokenEndpoint(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
clientID, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"})
// The secret embeds the client id (Tailscale's derive-from-secret trick).
assert.Contains(t, secret, clientID)
// Mint via the request body.
status, m := mintToken(t, baseURL, clientID, secret, "", false)
require.Equalf(t, http.StatusOK, status, "%v", m)
assert.Equal(t, "Bearer", m["token_type"])
assert.InDelta(t, 3600, m["expires_in"], 0, "fixed 1h, in seconds")
tok, _ := m["access_token"].(string)
assert.Contains(t, tok, "hskey-oauthtok-", "distinct prefix from admin keys")
// Mint via HTTP Basic (x/oauth2 auto-detect probes Basic first).
statusBasic, mBasic := mintToken(t, baseURL, clientID, secret, "", true)
require.Equalf(t, http.StatusOK, statusBasic, "%v", mBasic)
assert.NotEmpty(t, mBasic["access_token"])
// Bad credentials → RFC 6749 invalid_client.
statusBad, mBad := mintToken(t, baseURL, clientID, "hskey-client-deadbeefdead-"+stringOf("0", 64), "", false)
assert.Equal(t, http.StatusUnauthorized, statusBad)
assert.Equal(t, "invalid_client", mBad["error"])
// The token response carries a bearer credential and must not be cached
// (RFC 6749 §5.1). mintToken consumes the body, so probe the header raw.
form := url.Values{"grant_type": {"client_credentials"}, "client_secret": {secret}}
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode()))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
resp.Body.Close()
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
}
func TestAPIv2OAuth_ScopeEnforcement(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
// A write-scoped token may create an auth key within its tag grant...
_, writeSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"})
writeTok := tokenFor(t, baseURL, writeSecret)
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, writeTok, []string{"tag:ci"}),
"write scope + in-grant tag allowed")
// ...but not with a tag outside its grant.
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, writeTok, []string{"tag:other"}),
"tag outside grant denied")
// A read-only token is denied the write.
_, readSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"})
readTok := tokenFor(t, baseURL, readSecret)
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, readTok, []string{"tag:ci"}),
"read scope denied write")
// The wrong write scope (devices:core) cannot create auth keys.
_, devSecret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"})
devTok := tokenFor(t, baseURL, devSecret)
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, devTok, []string{"tag:ci"}),
"wrong scope denied")
// The "all" super-scope grants auth_keys.
_, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:ci"})
allTok := tokenFor(t, baseURL, allSecret)
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, allTok, []string{"tag:ci"}),
"all grants auth_keys")
// A token minted from the all-powerful client but narrowed to read-only is
// itself denied writes: narrowing is enforced on the minted token.
statusNarrow, mNarrow := mintToken(t, baseURL, "", allSecret, "auth_keys:read", false)
require.Equalf(t, http.StatusOK, statusNarrow, "narrow mint: %v", mNarrow)
narrowed, _ := mNarrow["access_token"].(string)
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, narrowed, []string{"tag:ci"}),
"token narrowed to :read denied write")
}
func TestAPIv2OAuth_ClientManagementScopes(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
// An oauth_keys token may create a client within its own grant...
_, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
okTok := tokenFor(t, baseURL, okSecret)
status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{
KeyType: "client", Scopes: []string{"oauth_keys:read"},
})
assert.Equalf(t, http.StatusOK, status, "oauth_keys creates an in-grant client: %s", body)
// ...but may NOT escalate by granting the new client a scope the token lacks.
statusEsc, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{
KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"},
})
assert.Equal(t, http.StatusForbidden, statusEsc, "oauth_keys token cannot mint a broader client")
// A token without oauth_keys cannot manage clients at all.
_, akSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"})
akTok := tokenFor(t, baseURL, akSecret)
statusDeny, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", akTok, apiv2.CreateKeyRequest{
KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"},
})
assert.Equal(t, http.StatusForbidden, statusDeny, "auth_keys (no oauth_keys) cannot create a client")
}
func TestAPIv2OAuth_TagOwnedBy(t *testing.T) {
app, baseURL, admin := newOAuthTestServer(t)
// tag:k8s is owned by tag:k8s-operator: the operator's tag delegation.
// tag:other exists but is owned by no one, so it tests grant denial (403)
// rather than tag-not-in-policy (400).
const policy = `{"tagOwners":{"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
_, err := app.state.SetPolicy([]byte(policy))
require.NoError(t, err)
_, err = app.state.SetPolicyInDB(policy)
require.NoError(t, err)
_, err = app.state.ReloadPolicy()
require.NoError(t, err)
_, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:k8s-operator"})
tok := tokenFor(t, baseURL, secret)
// Exact-match tag: allowed.
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s-operator"}),
"a token may use its own tag")
// Owned-by tag: allowed (tag:k8s is owned by tag:k8s-operator).
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s"}),
"a token may use a tag owned by its tag")
// Unrelated tag: denied.
assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, tok, []string{"tag:other"}),
"a token may not use an unowned tag")
}
// TestAPIv2OAuth_NoClientExistenceOracle asserts a token that cannot read OAuth
// clients gets the same response for a real client id as for a missing key, so
// it cannot enumerate which ids are OAuth clients.
func TestAPIv2OAuth_NoClientExistenceOracle(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
// A token holding only auth_keys:read (no oauth_keys:read).
_, akSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"})
akTok := tokenFor(t, baseURL, akSecret)
statusReal := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, akTok)
statusMissing := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/deadbeefdead", akTok)
assert.Equal(t, statusMissing, statusReal,
"a token without oauth_keys:read must not distinguish a real client id from a missing key")
assert.NotEqual(t, http.StatusOK, statusReal, "the client must not be readable")
// A token that does hold oauth_keys:read can read the client.
_, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"})
okTok := tokenFor(t, baseURL, okSecret)
statusOK := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, okTok)
assert.Equal(t, http.StatusOK, statusOK, "oauth_keys:read may read the client")
}
// TestAPIv2OAuth_SetDeviceTagsGrant asserts a devices:core token may only set
// tags within its grant on a device. Without the grant check, the scope alone
// would let a token stamp any existing policy tag (e.g. tag:other) onto any node.
func TestAPIv2OAuth_SetDeviceTagsGrant(t *testing.T) {
app, baseURL, admin := newOAuthTestServer(t)
// A registered, user-owned node to retag.
user := app.state.CreateUserForTest("dut")
node := app.state.CreateRegisteredNodeForTest(user, "dut")
node.User = user
view := app.state.PutNodeInStoreForTest(*node)
devURL := baseURL + "/api/v2/device/" + view.StringID() + "/tags"
// A devices:core token granted only tag:ci.
_, secret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"})
tok := tokenFor(t, baseURL, secret)
// In-grant tag is allowed.
st, body := apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:ci"}})
assert.Equalf(t, http.StatusOK, st, "in-grant tag: %s", body)
// An existing policy tag outside the token's grant is denied, not silently set.
st, _ = apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:other"}})
assert.Equal(t, http.StatusForbidden, st, "out-of-grant tag must be denied")
// An admin API key is unrestricted.
st, body = apiPost(t, devURL, admin, map[string]any{"tags": []string{"tag:other"}})
assert.Equalf(t, http.StatusOK, st, "admin may set any policy tag: %s", body)
}
// TestAPIv2OAuth_UndefinedTagRejected asserts an OAuth token cannot create a
// client or auth key carrying a tag absent from policy (matching SetNodeTags),
// while an admin key retains the historical syntax-only validation.
func TestAPIv2OAuth_UndefinedTagRejected(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
// A token that may create clients and auth keys, holding tag:ci (in policy).
_, secret := createClient(t, baseURL, admin, []string{"oauth_keys", "auth_keys"}, []string{"tag:ci"})
tok := tokenFor(t, baseURL, secret)
// A token-created auth key with a tag not in policy is rejected.
assert.Equal(t, http.StatusBadRequest, createTaggedKey(t, baseURL, tok, []string{"tag:undefined"}),
"token may not create an auth key with an undefined tag")
// A token-created client with a tag not in policy is rejected.
st, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", tok, apiv2.CreateKeyRequest{
KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:undefined"},
})
assert.Equal(t, http.StatusBadRequest, st, "token may not create a client with an undefined tag")
// An admin key keeps the historical behaviour: a syntactically valid but
// undefined tag is accepted (consistent with the v1/CLI pre-auth-key path).
assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:adminhistorical"}),
"admin retains syntax-only tag validation")
}
// TestAPIv2OAuth_DenialBranches covers the token-endpoint and bearer-dispatch
// failure paths: each must fail closed with the right status and no session.
func TestAPIv2OAuth_DenialBranches(t *testing.T) {
_, baseURL, _ := newOAuthTestServer(t)
tokenURL := baseURL + "/api/v2/oauth/token"
keysURL := baseURL + "/api/v2/tailnet/-/keys"
post := func(form string) (int, map[string]any) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, tokenURL, strings.NewReader(form))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var m map[string]any
_ = json.Unmarshal(data, &m)
return resp.StatusCode, m
}
// No credentials → invalid_client.
st, m := post("grant_type=client_credentials")
assert.Equal(t, http.StatusUnauthorized, st)
assert.Equal(t, "invalid_client", m["error"])
// Wrong grant_type → unsupported_grant_type.
st, m = post("grant_type=authorization_code&client_secret=x")
assert.Equal(t, http.StatusBadRequest, st)
assert.Equal(t, "unsupported_grant_type", m["error"])
// Unknown secret → invalid_client.
st, m = post("grant_type=client_credentials&client_secret=not-a-real-secret")
assert.Equal(t, http.StatusUnauthorized, st)
assert.Equal(t, "invalid_client", m["error"])
// Bearer dispatch: every malformed/unknown bearer is unauthorized.
for _, bearer := range []string{
"hskey-oauthtok-deadbeefdead-" + stringOf("0", 64), // well-formed prefix, unknown token
"hskey-oauthtok-garbage", // malformed OAuth token
"hskey-client-deadbeefdead-" + stringOf("0", 64), // client secret presented as API bearer
"totally-bogus",
} {
assert.Equalf(t, http.StatusUnauthorized, apiGet(t, keysURL, bearer),
"bearer %q must be unauthorized", bearer)
}
}
// TestAPIv2OAuth_KeysMultiplexIsolation asserts the multiplexed keys endpoint
// keeps the two kinds isolated by scope: a token cannot list or delete a kind it
// lacks the scope for, and an admin key sees both.
func TestAPIv2OAuth_KeysMultiplexIsolation(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
keysURL := baseURL + "/api/v2/tailnet/-/keys"
clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
require.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:ci"}))
listKinds := func(bearer string) map[string]int {
st, body := apiReq(t, http.MethodGet, keysURL, bearer, nil)
require.Equalf(t, http.StatusOK, st, "%s", body)
var out struct {
Keys []apiv2.Key `json:"keys"`
}
require.NoError(t, json.Unmarshal(body, &out))
kinds := map[string]int{}
for _, k := range out.Keys {
kinds[k.KeyType]++
}
return kinds
}
_, akReadSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"})
akKinds := listKinds(tokenFor(t, baseURL, akReadSecret))
assert.Zero(t, akKinds["client"], "auth_keys:read must not see OAuth clients")
assert.Positive(t, akKinds["auth"], "auth_keys:read sees auth keys")
_, okReadSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"})
okKinds := listKinds(tokenFor(t, baseURL, okReadSecret))
assert.Zero(t, okKinds["auth"], "oauth_keys:read must not see auth keys")
assert.Positive(t, okKinds["client"], "oauth_keys:read sees OAuth clients")
adminKinds := listKinds(admin)
assert.Positive(t, adminKinds["auth"])
assert.Positive(t, adminKinds["client"])
// An auth_keys (write) token cannot delete an OAuth client; it survives.
_, akWriteSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"})
stDel, _ := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, akWriteSecret), nil)
assert.NotEqual(t, http.StatusOK, stDel, "auth_keys token must not delete an OAuth client")
assert.Positive(t, listKinds(admin)["client"], "client survives the denied delete")
// An oauth_keys (write) token deletes the client.
_, okWriteSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
stDel2, body2 := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, okWriteSecret), nil)
assert.Equalf(t, http.StatusOK, stDel2, "oauth_keys deletes the client: %s", body2)
}
func stringOf(s string, n int) string {
return strings.Repeat(s, n)
}
+26 -7
View File
@@ -319,6 +319,11 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
revokedKeyGCChan = revokedKeyTicker.C
}
// OAuth access tokens are short-lived (1h) and re-minted on demand; reap
// expired rows hourly so the table stays bounded.
accessTokenTicker := time.NewTicker(time.Hour)
defer accessTokenTicker.Stop()
for {
select {
case <-ctx.Done():
@@ -335,6 +340,14 @@ func (h *Headscale) scheduledTasks(ctx context.Context) {
log.Info().Int("count", reaped).Msg("reaped revoked pre-auth keys")
}
case <-accessTokenTicker.C:
reaped, err := h.state.DeleteExpiredAccessTokens(time.Now())
if err != nil {
log.Error().Err(err).Msg("reaping expired oauth access tokens")
} else if reaped > 0 {
log.Debug().Int64("count", reaped).Msg("reaped expired oauth access tokens")
}
case <-expireTicker.C:
var (
expiredNodeChanges []change.Change
@@ -627,20 +640,26 @@ func (h *Headscale) Serve() error {
Cfg: h.cfg,
})
// The Headscale v2 API. It is served only over the remote
// listener (Basic/Bearer auth); no unix-socket mount yet, as nothing local
// calls it — the CLI still speaks v1.
// The Headscale v2 API. Served behind Basic/Bearer auth on the remote
// listener, and over the local unix socket (local trust) so the CLI can
// manage OAuth clients through the same v2 keys handler the Tailscale
// ecosystem uses.
humaV2Mux, _ := apiv2.Handler(apiv2.Backend{
State: h.state,
Change: h.Change,
Cfg: h.cfg,
})
// Serve the Huma API over the unix socket without TLS or auth: socket access
// implies trust. WithLocalTrust marks these requests so the security
// middleware skips the API-key check.
// Serve both Huma APIs over the unix socket without TLS or auth: socket
// access implies trust. WithLocalTrust marks these requests so each API's
// security middleware skips the credential check. v2 paths route to the v2
// mux; everything else (the v1 paths) to v1.
socketHandler := http.NewServeMux()
socketHandler.Handle("/api/v2/", apiv2.WithLocalTrust(humaV2Mux))
socketHandler.Handle("/", apiv1.WithLocalTrust(humaMux))
socketServer := &http.Server{
Handler: apiv1.WithLocalTrust(humaMux),
Handler: socketHandler,
ReadTimeout: types.HTTPTimeout,
}
+75
View File
@@ -831,6 +831,75 @@ WHERE user_id IS NULL
},
Rollback: func(db *gorm.DB) error { return nil },
},
{
// Add the OAuth client + access token tables backing the v2 API's
// OAuth client-credentials flow. They mirror the api_keys /
// pre_auth_keys security model: a public id/prefix plus an Argon2id
// hash of the secret.
//
// SQLite uses explicit DDL that matches schema.sql byte-for-byte
// (the squibble digest is the SQLite source of truth). Postgres,
// which has no digest and rejects SQLite-isms like AUTOINCREMENT,
// uses dialect-aware AutoMigrate, mirroring InitSchema's fresh-DB
// table creation so an existing Postgres deployment can upgrade.
ID: "202606211200-oauth-clients-and-tokens",
Migrate: func(tx *gorm.DB) error {
if tx.Migrator().HasTable(&types.OAuthClient{}) &&
tx.Migrator().HasTable(&types.OAuthAccessToken{}) {
return nil
}
if tx.Name() != "sqlite" {
return tx.AutoMigrate(&types.OAuthClient{}, &types.OAuthAccessToken{})
}
if !tx.Migrator().HasTable(&types.OAuthClient{}) {
err := tx.Exec(`CREATE TABLE oauth_clients(
id integer PRIMARY KEY AUTOINCREMENT,
client_id text,
secret_hash blob,
scopes text,
tags text,
description text,
user_id integer,
created_at datetime,
revoked datetime
)`).Error
if err != nil {
return fmt.Errorf("creating oauth_clients table: %w", err)
}
err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`).Error
if err != nil {
return fmt.Errorf("creating oauth_clients index: %w", err)
}
}
if !tx.Migrator().HasTable(&types.OAuthAccessToken{}) {
err := tx.Exec(`CREATE TABLE oauth_access_tokens(
id integer PRIMARY KEY AUTOINCREMENT,
prefix text,
hash blob,
client_id text,
scopes text,
tags text,
expiration datetime,
created_at datetime
)`).Error
if err != nil {
return fmt.Errorf("creating oauth_access_tokens table: %w", err)
}
err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`).Error
if err != nil {
return fmt.Errorf("creating oauth_access_tokens index: %w", err)
}
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
},
)
@@ -842,6 +911,8 @@ WHERE user_id IS NULL
&types.APIKey{},
&types.Node{},
&types.Policy{},
&types.OAuthClient{},
&types.OAuthAccessToken{},
)
if err != nil {
return err
@@ -857,6 +928,8 @@ WHERE user_id IS NULL
`DROP INDEX IF EXISTS "idx_name_provider_identifier"`,
`DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`,
`DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`,
`DROP INDEX IF EXISTS "idx_oauth_clients_client_id"`,
`DROP INDEX IF EXISTS "idx_oauth_access_tokens_prefix"`,
}
for _, dropSQL := range dropIndexes {
@@ -875,6 +948,8 @@ WHERE user_id IS NULL
`CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`,
`CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`,
`CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`,
`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`,
`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`,
}
for _, indexSQL := range indexes {
+385
View File
@@ -0,0 +1,385 @@
package db
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"runtime"
"slices"
"strings"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"golang.org/x/crypto/argon2"
"gorm.io/gorm"
"tailscale.com/util/rands"
"tailscale.com/util/set"
)
const (
// OAuth client secret: hskey-client-<clientID(12)>-<secret(64)>. The clientID
// is the public, indexed lookup key (the analogue of an API key's prefix) and
// is embedded in the secret so the token endpoint can derive it. The prefix
// itself lives in the types package ([types.OAuthClientPrefix]).
oauthClientIDLength = 12
oauthClientSecretLength = 64
// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct
// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth
// middleware dispatch a scoped token from an all-access admin key alone.
accessTokenPrefixLength = 12
accessTokenSecretLength = 64
)
var (
ErrOAuthClientNotFound = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound)
ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret")
ErrOAuthClientRevoked = errors.New("oauth client revoked")
ErrAccessTokenNotFound = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound)
ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token")
ErrAccessTokenExpired = errors.New("oauth access token expired")
ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted")
errSecretHashMalformed = errors.New("malformed secret hash")
errSecretMismatch = errors.New("secret does not match hash")
)
// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
// lane). They are encoded into every stored hash, so raising them later still
// verifies credentials stored under the old cost.
const (
argon2Time = 2
argon2Memory = 19 * 1024
argon2Threads = 1
argon2KeyLen = 32
argon2SaltLen = 16
)
// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB and
// the unauthenticated OAuth token endpoint runs one per attempt, so an unbounded
// flood could exhaust memory. ponytail: a global semaphore sized to GOMAXPROCS;
// revisit only if credential hashing ever becomes a throughput bottleneck.
var argon2Limiter = make(chan struct{}, max(2, runtime.GOMAXPROCS(0)))
// hashSecret hashes a credential secret with Argon2id, encoded in PHC string
// form so the parameters travel with the hash. Argon2id is the current OWASP
// recommendation, replacing bcrypt for new credential storage.
func hashSecret(secret string) ([]byte, error) {
salt := make([]byte, argon2SaltLen)
_, err := rand.Read(salt)
if err != nil {
return nil, fmt.Errorf("generating salt: %w", err)
}
hash := argon2.IDKey([]byte(secret), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argon2Memory, argon2Time, argon2Threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
)
return []byte(encoded), nil
}
// verifySecret reports whether secret matches a hashSecret-encoded hash. It
// reads the cost parameters from the stored hash and compares in constant time
// so a mismatch leaks no timing signal.
func verifySecret(encoded []byte, secret string) error {
parts := strings.Split(string(encoded), "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return errSecretHashMalformed
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { //nolint:noinlineerr
return errSecretHashMalformed
}
var (
memory, time uint32
threads uint8
)
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { //nolint:noinlineerr
return errSecretHashMalformed
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return errSecretHashMalformed
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return errSecretHashMalformed
}
argon2Limiter <- struct{}{}
//nolint:gosec // want is a 32-byte hash read back from storage, no overflow
got := argon2.IDKey([]byte(secret), salt, time, memory, threads, uint32(len(want)))
<-argon2Limiter
if subtle.ConstantTimeCompare(got, want) != 1 {
return errSecretMismatch
}
return nil
}
// CreateOAuthClient creates a new [types.OAuthClient] and returns the plaintext
// secret (shown ONCE) alongside the stored client. creatorUserID is the user who
// created it (informational), or nil.
func (hsdb *HSDatabase) CreateOAuthClient(
scopes, tags []string,
description string,
creatorUserID *uint,
) (string, *types.OAuthClient, error) {
tags, err := validateACLTags(tags)
if err != nil {
return "", nil, err
}
scopes = set.SetOf(scopes).Slice()
slices.Sort(scopes)
clientID := rands.HexString(oauthClientIDLength)
secret := rands.HexString(oauthClientSecretLength)
secretStr := types.OAuthClientPrefix + clientID + "-" + secret
hash, err := hashSecret(secret)
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
client := types.OAuthClient{
ClientID: clientID,
SecretHash: hash,
Scopes: scopes,
Tags: tags,
Description: description,
UserID: creatorUserID,
CreatedAt: &now,
}
err = hsdb.Write(func(tx *gorm.DB) error {
return tx.Save(&client).Error
})
if err != nil {
return "", nil, fmt.Errorf("saving oauth client: %w", err)
}
return secretStr, &client, nil
}
// AuthenticateOAuthClient validates a presented client secret and returns the
// matching, unrevoked [types.OAuthClient]. The client id is derived from the
// secret (its middle segment), so any separately-supplied client_id is
// redundant, matching Tailscale, where get-authkey passes a dummy id and the
// server derives the real one from the secret.
func (hsdb *HSDatabase) AuthenticateOAuthClient(secretStr string) (*types.OAuthClient, error) {
if secretStr == "" {
return nil, ErrOAuthClientFailedToParse
}
// Tailscale allows the secret to carry optional ?key=value attributes when
// used directly as an auth key; strip them before parsing.
secretStr, _, _ = strings.Cut(secretStr, "?")
_, rest, found := strings.Cut(secretStr, types.OAuthClientPrefix)
if !found {
return nil, ErrOAuthClientFailedToParse
}
clientID, secret, err := parsePrefixedKey(
rest,
oauthClientIDLength,
oauthClientSecretLength,
ErrOAuthClientFailedToParse,
)
if err != nil {
return nil, err
}
var client types.OAuthClient
if err := hsdb.DB.First(&client, "client_id = ?", clientID).Error; err != nil { //nolint:noinlineerr
return nil, ErrOAuthClientNotFound
}
if err := verifySecret(client.SecretHash, secret); err != nil { //nolint:noinlineerr
return nil, fmt.Errorf("invalid oauth client secret: %w", err)
}
if client.Revoked != nil {
return nil, ErrOAuthClientRevoked
}
return &client, nil
}
// GetOAuthClientByClientID returns a [types.OAuthClient] by its public client id.
func (hsdb *HSDatabase) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {
var client types.OAuthClient
if result := hsdb.DB.First(&client, "client_id = ?", clientID); result.Error != nil {
return nil, result.Error
}
return &client, nil
}
// ListOAuthClients returns every [types.OAuthClient].
func (hsdb *HSDatabase) ListOAuthClients() ([]types.OAuthClient, error) {
clients := []types.OAuthClient{}
err := hsdb.DB.Find(&clients).Error
if err != nil {
return nil, err
}
return clients, nil
}
// RevokeOAuthClient deletes a client and all access tokens it issued. An unknown
// client id returns [ErrOAuthClientNotFound], so a repeated DELETE is a clean
// 404. Unlike pre-auth keys (which soft-revoke for node-registration history), an
// OAuth client has no such history and is removed outright, matching Tailscale.
func (hsdb *HSDatabase) RevokeOAuthClient(clientID string) error {
return hsdb.Write(func(tx *gorm.DB) error {
err := tx.Where("client_id = ?", clientID).
Delete(&types.OAuthAccessToken{}).Error
if err != nil {
return fmt.Errorf("deleting oauth access tokens: %w", err)
}
res := tx.Where("client_id = ?", clientID).Delete(&types.OAuthClient{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrOAuthClientNotFound
}
return nil
})
}
// MintAccessToken stores a new [types.OAuthAccessToken] for clientID with the
// given (already narrowed) scopes/tags and expiration, returning the plaintext
// token (shown ONCE).
func (hsdb *HSDatabase) MintAccessToken(
clientID string,
scopes, tags []string,
expiration *time.Time,
) (string, *types.OAuthAccessToken, error) {
prefix := rands.HexString(accessTokenPrefixLength)
secret := rands.HexString(accessTokenSecretLength)
tokenStr := types.AccessTokenPrefix + prefix + "-" + secret
hash, err := hashSecret(secret)
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
token := types.OAuthAccessToken{
Prefix: prefix,
Hash: hash,
ClientID: clientID,
Scopes: scopes,
Tags: tags,
Expiration: expiration,
CreatedAt: &now,
}
// Mint inside a transaction that re-checks the client still exists and is
// not revoked, so a mint cannot complete against a client being deleted.
err = hsdb.Write(func(tx *gorm.DB) error {
var client types.OAuthClient
err := tx.First(&client, "client_id = ?", clientID).Error
if err != nil {
return ErrOAuthClientNotFound
}
if client.Revoked != nil {
return ErrOAuthClientRevoked
}
return tx.Save(&token).Error
})
if err != nil {
return "", nil, fmt.Errorf("saving oauth access token: %w", err)
}
return tokenStr, &token, nil
}
// AuthenticateAccessToken validates a presented bearer token and returns the
// matching, unexpired [types.OAuthAccessToken] (carrying its granted scopes and
// tags). A non-nil error means the token is missing, malformed, or expired.
func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAccessToken, error) {
if tokenStr == "" {
return nil, ErrAccessTokenFailedToParse
}
_, rest, found := strings.Cut(tokenStr, types.AccessTokenPrefix)
if !found {
return nil, ErrAccessTokenFailedToParse
}
prefix, secret, err := parsePrefixedKey(
rest,
accessTokenPrefixLength,
accessTokenSecretLength,
ErrAccessTokenFailedToParse,
)
if err != nil {
return nil, err
}
var token types.OAuthAccessToken
if err := hsdb.DB.First(&token, "prefix = ?", prefix).Error; err != nil { //nolint:noinlineerr
return nil, ErrAccessTokenNotFound
}
if err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr
return nil, fmt.Errorf("invalid oauth access token: %w", err)
}
if token.Expiration != nil && token.Expiration.Before(time.Now()) {
return nil, ErrAccessTokenExpired
}
// Bind validity to the issuing client: a token whose client has been
// revoked or deleted is rejected. This closes a mint/revoke race (where a
// token could be inserted after the client's tokens were purged) and any
// orphan left by manual deletion or a future soft-revoke path.
var client types.OAuthClient
if err := hsdb.DB.First(&client, "client_id = ?", token.ClientID).Error; err != nil { //nolint:noinlineerr
return nil, ErrAccessTokenClientRevoked
}
if client.Revoked != nil {
return nil, ErrAccessTokenClientRevoked
}
return &token, nil
}
// DeleteExpiredAccessTokens hard-deletes every access token that expired before
// cutoff, returning how many were removed. Auth-time checks already reject
// expired tokens; the hourly reaper (see app.go) calls this only to keep the
// table from growing unbounded.
func (hsdb *HSDatabase) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) {
res := hsdb.DB.Where("expiration IS NOT NULL AND expiration < ?", cutoff).
Delete(&types.OAuthAccessToken{})
return res.RowsAffected, res.Error
}
+204
View File
@@ -0,0 +1,204 @@
package db
import (
"strings"
"sync"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVerifySecretConcurrent runs more concurrent verifications than the Argon2
// concurrency semaphore admits, asserting the limiter releases correctly (no
// deadlock) and stays correct under contention. Run with -race.
func TestVerifySecretConcurrent(t *testing.T) {
hash, err := hashSecret("s3cr3t")
require.NoError(t, err)
const n = 64
var wg sync.WaitGroup
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(i int) {
defer wg.Done()
if i%2 == 0 {
errs[i] = verifySecret(hash, "s3cr3t")
} else {
errs[i] = verifySecret(hash, "wrong")
}
}(i)
}
wg.Wait()
for i, e := range errs {
if i%2 == 0 {
assert.NoError(t, e, "correct secret must verify")
} else {
assert.Error(t, e, "wrong secret must fail")
}
}
}
func TestOAuthClientCreateAndAuthenticate(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
secret, client, err := db.CreateOAuthClient(
[]string{"auth_keys", "devices:core"},
[]string{"tag:ci"},
"my client",
nil,
)
require.NoError(t, err)
require.NotNil(t, client)
// Secret carries the public client id as its middle segment, so it can be
// derived from the secret alone (the Tailscale get-authkey trick).
assert.True(t, strings.HasPrefix(secret, "hskey-client-"+client.ClientID+"-"))
// Scopes/tags are deduplicated and sorted for stable storage.
assert.Equal(t, []string{"auth_keys", "devices:core"}, client.Scopes)
assert.Equal(t, []string{"tag:ci"}, client.Tags)
// Only the Argon2id hash is stored, never the plaintext.
assert.NotEmpty(t, client.SecretHash)
assert.True(t, strings.HasPrefix(string(client.SecretHash), "$argon2id$"))
// The secret authenticates, deriving the client id from the secret itself.
got, err := db.AuthenticateOAuthClient(secret)
require.NoError(t, err)
assert.Equal(t, client.ClientID, got.ClientID)
// A truncated/garbage secret does not.
_, err = db.AuthenticateOAuthClient("hskey-client-deadbeef-nope")
require.Error(t, err)
// Wrong secret for a real client id is rejected by the constant-time compare.
_, err = db.AuthenticateOAuthClient("hskey-client-" + client.ClientID + "-" + strings.Repeat("0", 64))
require.Error(t, err)
}
func TestHashSecretRoundTrip(t *testing.T) {
const secret = "a-high-entropy-credential-secret"
encoded, err := hashSecret(secret)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(string(encoded), "$argon2id$v="))
// The same secret hashes to a different value each time (random salt) yet
// still verifies.
encoded2, err := hashSecret(secret)
require.NoError(t, err)
assert.NotEqual(t, encoded, encoded2)
require.NoError(t, verifySecret(encoded, secret))
require.ErrorIs(t, verifySecret(encoded, "wrong-secret"), errSecretMismatch)
require.ErrorIs(t, verifySecret([]byte("not-a-phc-string"), secret), errSecretHashMalformed)
}
func TestOAuthClientRevoke(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
secret, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
require.NoError(t, err)
// A token minted by the client survives only until the client is revoked.
_, _, err = db.MintAccessToken(client.ClientID, client.Scopes, client.Tags, nil)
require.NoError(t, err)
require.NoError(t, db.RevokeOAuthClient(client.ClientID))
// The client no longer authenticates and a repeated revoke is a clean 404.
_, err = db.AuthenticateOAuthClient(secret)
require.Error(t, err)
require.ErrorIs(t, db.RevokeOAuthClient(client.ClientID), ErrOAuthClientNotFound)
}
func TestOAuthAccessTokenMintAuthenticateExpire(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
_, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
require.NoError(t, err)
future := time.Now().Add(time.Hour)
tokenStr, token, err := db.MintAccessToken(
client.ClientID,
[]string{"auth_keys"},
[]string{"tag:ci"},
&future,
)
require.NoError(t, err)
assert.True(t, strings.HasPrefix(tokenStr, "hskey-oauthtok-"))
got, err := db.AuthenticateAccessToken(tokenStr)
require.NoError(t, err)
assert.Equal(t, client.ClientID, got.ClientID)
assert.Equal(t, []string{"auth_keys"}, got.Scopes)
assert.Equal(t, []string{"tag:ci"}, got.Tags)
// An expired token is rejected even though the row still exists.
past := time.Now().Add(-time.Hour)
expiredStr, _, err := db.MintAccessToken(client.ClientID, nil, nil, &past)
require.NoError(t, err)
_, err = db.AuthenticateAccessToken(expiredStr)
require.ErrorIs(t, err, ErrAccessTokenExpired)
// The reaper deletes the expired row; the live token is untouched.
n, err := db.DeleteExpiredAccessTokens(time.Now())
require.NoError(t, err)
assert.Equal(t, int64(1), n)
_ = token
_, err = db.AuthenticateAccessToken(tokenStr)
require.NoError(t, err)
}
// TestAccessTokenRejectedWhenClientGone asserts a token whose issuing client no
// longer exists (orphaned by a delete/revoke race) is rejected, even though the
// token row itself is valid and unexpired.
func TestAccessTokenRejectedWhenClientGone(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
_, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
require.NoError(t, err)
future := time.Now().Add(time.Hour)
tokenStr, _, err := db.MintAccessToken(client.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future)
require.NoError(t, err)
_, err = db.AuthenticateAccessToken(tokenStr)
require.NoError(t, err)
// Delete only the client row, leaving the token orphaned (the state a
// mint/revoke race or manual deletion would produce).
require.NoError(t, db.DB.Where("client_id = ?", client.ClientID).Delete(&types.OAuthClient{}).Error)
_, err = db.AuthenticateAccessToken(tokenStr)
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)
// A soft-revoked client (row present, Revoked set) is likewise rejected.
_, client2, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
require.NoError(t, err)
tokenStr2, _, err := db.MintAccessToken(client2.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future)
require.NoError(t, err)
now := time.Now()
require.NoError(t, db.DB.Model(&types.OAuthClient{}).
Where("client_id = ?", client2.ClientID).Update("revoked", now).Error)
_, err = db.AuthenticateAccessToken(tokenStr2)
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)
}
+24 -14
View File
@@ -25,6 +25,26 @@ var (
ErrPreAuthKeyACLTagInvalid = errors.New("auth-key tag is invalid")
)
// validateACLTags deduplicates, sorts, and checks that every tag carries the
// "tag:" prefix. Shared by the pre-auth-key and OAuth credential paths so both
// enforce the same tag shape.
func validateACLTags(tags []string) ([]string, error) {
tags = set.SetOf(tags).Slice()
slices.Sort(tags)
for _, tag := range tags {
if !strings.HasPrefix(tag, "tag:") {
return nil, fmt.Errorf(
"%w: '%s' did not begin with 'tag:'",
ErrPreAuthKeyACLTagInvalid,
tag,
)
}
}
return tags, nil
}
func (hsdb *HSDatabase) CreatePreAuthKey(
uid *types.UserID,
reusable bool,
@@ -76,20 +96,9 @@ func CreatePreAuthKey(
userID = &user.ID
}
// Remove duplicates and sort for consistency
aclTags = set.SetOf(aclTags).Slice()
slices.Sort(aclTags)
// TODO(kradalby): factor out and create a reusable tag validation,
// check if there is one in Tailscale's lib.
for _, tag := range aclTags {
if !strings.HasPrefix(tag, "tag:") {
return nil, fmt.Errorf(
"%w: '%s' did not begin with 'tag:'",
ErrPreAuthKeyACLTagInvalid,
tag,
)
}
aclTags, err := validateACLTags(aclTags)
if err != nil {
return nil, err
}
now := time.Now().UTC()
@@ -228,6 +237,7 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) {
// separator-based to handle dashes in base64 URL-safe characters.
func parsePrefixedKey(
prefixAndSecret string,
//nolint:unparam // kept explicit though every credential kind uses a 12-char prefix and 64-char secret today
prefixLen, secretLen int,
parseErr error,
) (string, string, error) {
+30
View File
@@ -70,6 +70,36 @@ CREATE TABLE api_keys(
);
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix);
-- OAuth 2.0 client-credentials clients for the v2 API. client_id is public and
-- embedded in the secret (hskey-client-<client_id>-<secret>); only the bcrypt
-- hash of the secret is stored. Mirrors the api_keys security model.
CREATE TABLE oauth_clients(
id integer PRIMARY KEY AUTOINCREMENT,
client_id text,
secret_hash blob,
scopes text,
tags text,
description text,
user_id integer,
created_at datetime,
revoked datetime
);
CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id);
-- Short-lived bearer access tokens minted by an oauth_client. Stored as a bcrypt
-- hash of the secret, looked up by prefix.
CREATE TABLE oauth_access_tokens(
id integer PRIMARY KEY AUTOINCREMENT,
prefix text,
hash blob,
client_id text,
scopes text,
tags text,
expiration datetime,
created_at datetime
);
CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix);
CREATE TABLE nodes(
id integer PRIMARY KEY AUTOINCREMENT,
machine_key text,
+42 -12
View File
@@ -85,8 +85,8 @@ func NewAuthProviderOIDC(
serverURL string,
cfg *types.OIDCConfig,
) (*AuthProviderOIDC, error) {
var err error
// grab oidc config if it hasn't been already
// Use the caller's context (bounded, see app.go) so a slow or unreachable
// issuer fails discovery within the timeout instead of hanging startup.
oidcProvider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("creating OIDC provider from issuer config: %w", err)
@@ -117,6 +117,15 @@ func NewAuthProviderOIDC(
}, nil
}
// cookiesSecure reports whether the OIDC cookies should carry the Secure flag.
// It keys off the configured server_url scheme, not req.TLS, so cookies stay
// Secure behind a TLS-terminating reverse proxy (where the proxy→Headscale hop
// is plain HTTP and req.TLS is nil). Deriving it from config avoids trusting a
// spoofable X-Forwarded-Proto header.
func (a *AuthProviderOIDC) cookiesSecure() bool {
return strings.HasPrefix(a.serverURL, "https://")
}
func (a *AuthProviderOIDC) AuthURL(authID types.AuthID) string {
return authPathURL(a.serverURL, "auth", authID)
}
@@ -156,10 +165,10 @@ func (a *AuthProviderOIDC) authHandler(
}
// Set the state and nonce cookies to protect against CSRF attacks
state := setCSRFCookie(writer, req, "state")
state := setCSRFCookie(writer, req, "state", a.cookiesSecure())
// Set the state and nonce cookies to protect against CSRF attacks
nonce := setCSRFCookie(writer, req, "nonce")
nonce := setCSRFCookie(writer, req, "nonce", a.cookiesSecure())
registrationInfo := AuthInfo{
AuthID: authID,
@@ -263,6 +272,11 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
return
}
// The state/nonce cookies have served their CSRF purpose; clear them so a
// single-use pair does not linger in the browser until MaxAge.
clearOIDCCallbackCookie(writer, stateCookieName)
clearOIDCCallbackCookie(writer, nonceCookieName)
nodeExpiry := a.determineNodeExpiry(idToken.Expiry)
var claims types.OIDCClaims
@@ -589,13 +603,17 @@ func doOIDCAuthorization(
return nil
}
// getAuthInfoFromState retrieves the registration ID from the state.
// getAuthInfoFromState retrieves and consumes the auth info for a state. The
// entry is removed on read so a state is single-use: a replayed callback cannot
// resolve the same auth session twice, even within the cache TTL.
func (a *AuthProviderOIDC) getAuthInfoFromState(state string) *AuthInfo {
authInfo, ok := a.authCache.Get(state)
if !ok {
return nil
}
a.authCache.Remove(state)
return &authInfo
}
@@ -658,14 +676,15 @@ func setRegisterConfirmCookie(
authID types.AuthID,
value string,
maxAge int,
secure bool,
) {
//nolint:gosec // G124: Secure set conditionally via req.TLS; HttpOnly + SameSite already set
//nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite already set
http.SetCookie(writer, &http.Cookie{
Name: registerConfirmCSRFCookie,
Value: value,
Path: "/register/confirm/" + authID.String(),
MaxAge: maxAge,
Secure: req.TLS != nil,
Secure: secure || req.TLS != nil,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
@@ -707,7 +726,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
CSRF: csrf,
})
setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds()))
setRegisterConfirmCookie(writer, req, authID, csrf, int(authCacheExpiration.Seconds()), a.cookiesSecure())
regData := authReq.RegistrationData()
@@ -824,7 +843,7 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
}
// Clear the CSRF cookie now that the registration is final.
setRegisterConfirmCookie(writer, req, authID, "", -1)
setRegisterConfirmCookie(writer, req, authID, "", -1, a.cookiesSecure())
content := renderRegistrationSuccessTemplate(user, newNode)
@@ -920,16 +939,27 @@ func getCookieName(baseName, value string) string {
return fmt.Sprintf("%s_%s", baseName, value[:n])
}
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) string {
// clearOIDCCallbackCookie expires a /oidc/callback cookie by name. Matching the
// path the cookie was set with is required for the browser to drop it.
func clearOIDCCallbackCookie(w http.ResponseWriter, name string) {
//nolint:gosec // G124: a deletion cookie (empty value, MaxAge<0); security attributes are moot
http.SetCookie(w, &http.Cookie{
Name: name,
Path: "/oidc/callback",
MaxAge: -1,
})
}
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string, secure bool) string {
val := rands.HexString(64)
//nolint:gosec // G124: Secure set conditionally via r.TLS; HttpOnly + SameSite set below
//nolint:gosec // G124: Secure from server_url scheme or req.TLS; HttpOnly + SameSite set below
c := &http.Cookie{
Path: "/oidc/callback",
Name: getCookieName(name, val),
Value: val,
MaxAge: int(time.Hour.Seconds()),
Secure: r.TLS != nil,
Secure: secure || r.TLS != nil,
HttpOnly: true,
// Lax, not Strict: the OIDC callback is a cross-site top-level GET
// redirect from the IdP that must still carry this cookie. Strict
+69 -1
View File
@@ -4,7 +4,9 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/hashicorp/golang-lru/v2/expirable"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -186,10 +188,76 @@ func TestSetCSRFCookieSameSite(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil)
setCSRFCookie(w, r, "state")
setCSRFCookie(w, r, "state", false)
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
assert.Equal(t, http.SameSiteLaxMode, cookies[0].SameSite,
"OIDC CSRF cookie must explicitly set SameSite=Lax")
}
// TestExtractCodeAndStateParam covers the callback's first trust-boundary
// checks: both params required, and a too-short state is rejected before
// getCookieName can slice out of range.
func TestExtractCodeAndStateParam(t *testing.T) {
_, _, err := extractCodeAndStateParamFromRequest(
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback", nil))
require.Error(t, err)
_, _, err = extractCodeAndStateParamFromRequest(
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abc", nil))
require.ErrorIs(t, err, errOIDCStateTooShort)
code, state, err := extractCodeAndStateParamFromRequest(
httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oidc/callback?code=c&state=abcdef0123", nil))
require.NoError(t, err)
assert.Equal(t, "c", code)
assert.Equal(t, "abcdef0123", state)
}
// TestGetAuthInfoFromStateSingleUse asserts a consumed OIDC state cannot be
// resolved twice, so a replayed callback cannot re-bind the same session.
func TestGetAuthInfoFromStateSingleUse(t *testing.T) {
a := &AuthProviderOIDC{
authCache: expirable.NewLRU[string, AuthInfo](16, nil, time.Minute),
}
a.authCache.Add("state-x", AuthInfo{Registration: true})
got := a.getAuthInfoFromState("state-x")
require.NotNil(t, got)
assert.True(t, got.Registration)
assert.Nil(t, a.getAuthInfoFromState("state-x"), "a consumed state must not resolve again")
}
// TestClearOIDCCallbackCookie asserts the cookie is expired (negative MaxAge) on
// the same path it was set with, so the browser drops it.
func TestClearOIDCCallbackCookie(t *testing.T) {
w := httptest.NewRecorder()
clearOIDCCallbackCookie(w, "state_abcdef")
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
assert.Equal(t, "state_abcdef", cookies[0].Name)
assert.Negative(t, cookies[0].MaxAge, "deletion cookie must have negative MaxAge")
}
// TestSetCSRFCookieSecure verifies the Secure flag is driven by the secure
// argument (derived from the configured https server_url), not only req.TLS, so
// cookies stay Secure behind a TLS-terminating reverse proxy where req.TLS is
// nil.
func TestSetCSRFCookieSecure(t *testing.T) {
r := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/abcdef0123456789", nil)
secureRec := httptest.NewRecorder()
setCSRFCookie(secureRec, r, "state", true)
require.Len(t, secureRec.Result().Cookies(), 1)
assert.True(t, secureRec.Result().Cookies()[0].Secure,
"https server_url must set Secure even when req.TLS is nil (proxy case)")
plainRec := httptest.NewRecorder()
setCSRFCookie(plainRec, r, "state", false)
require.Len(t, plainRec.Result().Cookies(), 1)
assert.False(t, plainRec.Result().Cookies()[0].Secure,
"plain-http server_url without req.TLS must not set Secure")
}
+6
View File
@@ -33,6 +33,12 @@ type PolicyManager interface {
// TagExists reports whether the given tag is defined in the policy.
TagExists(tag string) bool
// TagOwnedByTags reports whether a credential holding ownerTags may apply
// tag: true if tag is one of ownerTags, or tag's tag-to-tag ownership chain
// transitively includes one of ownerTags. Authorises the tags an OAuth
// access token may set on the auth keys it mints.
TagOwnedByTags(tag string, ownerTags []string) bool
// NodeCanApproveRoute reports whether the given node can approve the given route.
NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool
+60
View File
@@ -968,6 +968,66 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
return false
}
// TagOwnedByTags reports whether a credential holding ownerTags is authorised to
// apply tag. It is true when tag is one of ownerTags, or when tag's tagOwners
// chain (tag-to-tag ownership) transitively includes one of ownerTags. This is
// the tag-level check used when an OAuth access token mints an auth key: the
// requested tags must each be owned by the token's tags, so an operator token
// tagged tag:k8s-operator may mint tag:k8s keys when the policy declares
// "tag:k8s": ["tag:k8s-operator"]. It is purely tag-relational and does not
// consult node IPs.
func (pm *PolicyManager) TagOwnedByTags(tag string, ownerTags []string) bool {
if pm == nil {
return false
}
owns := make(map[string]bool, len(ownerTags))
for _, t := range ownerTags {
owns[t] = true
}
// A credential may always apply a tag it directly holds; this needs no policy.
if owns[tag] {
return true
}
pm.mu.Lock()
defer pm.mu.Unlock()
// Owned-by delegation requires the policy's tagOwners.
if pm.pol == nil {
return false
}
// Walk tag-to-tag ownership transitively, guarding against cycles.
visited := make(map[Tag]bool)
var walk func(t Tag) bool
walk = func(t Tag) bool {
if visited[t] {
return false
}
visited[t] = true
for _, owner := range pm.pol.TagOwners[t] {
ot, ok := owner.(*Tag)
if !ok {
continue
}
if owns[string(*ot)] || walk(*ot) {
return true
}
}
return false
}
return walk(Tag(tag))
}
// userMatchesOwner checks if a user matches a tag owner entry.
// This is used as a fallback when the node's IP is not in the [PolicyManager.tagOwnerMap].
func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool {
+72
View File
@@ -2470,3 +2470,75 @@ func TestPeerRelayGrantMakesRelayVisible(t *testing.T) {
})
}
}
func TestTagOwnedByTags(t *testing.T) {
// tag:leaf is owned by tag:mid, which is owned by tag:root: a tag-to-tag
// delegation chain, the shape an operator token uses to mint narrower keys.
const policy = `{
"tagOwners": {
"tag:root": [],
"tag:mid": ["tag:root"],
"tag:leaf": ["tag:mid"],
"tag:lone": []
},
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
}`
pm, err := NewPolicyManager([]byte(policy), nil, types.Nodes{}.ViewSlice())
require.NoError(t, err)
tests := []struct {
name string
tag string
ownerTags []string
want bool
}{
{
name: "directly held tag needs no policy",
tag: "tag:lone",
ownerTags: []string{"tag:lone"},
want: true,
},
{
name: "one-hop owned-by",
tag: "tag:mid",
ownerTags: []string{"tag:root"},
want: true,
},
{
name: "transitive chain root owns leaf",
tag: "tag:leaf",
ownerTags: []string{"tag:root"},
want: true,
},
{
name: "owning one link does not grant a sibling",
tag: "tag:lone",
ownerTags: []string{"tag:root"},
want: false,
},
{
name: "unowned tag denied",
tag: "tag:leaf",
ownerTags: []string{"tag:unrelated"},
want: false,
},
{
name: "empty owners deny a delegated tag",
tag: "tag:leaf",
ownerTags: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, pm.TagOwnedByTags(tt.tag, tt.ownerTags))
})
}
t.Run("nil policy manager denies", func(t *testing.T) {
var nilPM *PolicyManager
require.False(t, nilPM.TagOwnedByTags("tag:leaf", []string{"tag:root"}))
})
}
+123
View File
@@ -0,0 +1,123 @@
// Package scope models the OAuth capability scopes the Headscale v2 API enforces
// and the rule for whether a granted set of scopes satisfies a required one.
//
// The vocabulary is taken from Tailscale's OpenAPI spec (the same scope names
// the Terraform provider and Kubernetes operator request), so a client written
// against Tailscale's scopes works unchanged against Headscale. The grant
// predicate is kept here, separate from the HTTP/huma layer in
// hscontrol/api/v2, so it can be tested exhaustively on its own.
package scope
import "strings"
// Scope is an OAuth capability an operation requires and a token grants. The names
// mirror Tailscale's API scopes; a "...:read" scope is the read-only subset of its
// write scope.
type Scope string
const (
// All and AllRead are Tailscale's forward-compatible super-scopes: "all"
// grants every other scope, "all:read" grants every :read subset.
All Scope = "all"
AllRead Scope = "all:read"
AuthKeys Scope = "auth_keys"
AuthKeysRead Scope = "auth_keys:read"
// OAuthKeys gates managing OAuth clients (keyType:"client" on the keys
// resource).
OAuthKeys Scope = "oauth_keys"
OAuthKeysRead Scope = "oauth_keys:read"
DevicesCore Scope = "devices:core"
DevicesCoreRead Scope = "devices:core:read"
DevicesRoutes Scope = "devices:routes"
DevicesRoutesRead Scope = "devices:routes:read"
PolicyFile Scope = "policy_file"
PolicyFileRead Scope = "policy_file:read"
FeatureSettings Scope = "feature_settings"
FeatureSettingsRead Scope = "feature_settings:read"
Users Scope = "users"
UsersRead Scope = "users:read"
)
const readSuffix = ":read"
// Known returns every scope in the vocabulary, in a stable order. Useful for
// exhaustive iteration in tests and documentation.
func Known() []Scope {
return []Scope{
All, AllRead,
AuthKeys, AuthKeysRead,
OAuthKeys, OAuthKeysRead,
DevicesCore, DevicesCoreRead,
DevicesRoutes, DevicesRoutesRead,
PolicyFile, PolicyFileRead,
FeatureSettings, FeatureSettingsRead,
Users, UsersRead,
}
}
// IsRead reports whether s is a read-only scope (its name ends with ":read").
func (s Scope) IsRead() bool {
return strings.HasSuffix(string(s), readSuffix)
}
// IsWrite reports whether s is a non-empty write scope.
func (s Scope) IsWrite() bool {
return s != "" && !s.IsRead()
}
// Parse converts scope strings (as stored on a token or client) into Scope values.
// 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 {
out[i] = Scope(s)
}
return out
}
// Grants reports whether the granted scopes satisfy the required want scope.
func Grants(granted []Scope, want Scope) bool {
for _, g := range granted {
if satisfies(g, want) {
return true
}
}
return false
}
// satisfies reports whether a single held scope satisfies want: exact match; a
// write scope grants its own :read subset; "all" grants everything; "all:read"
// grants any :read scope.
func satisfies(have, want Scope) bool {
if have == want || have == All {
return true
}
if have == AllRead {
return want.IsRead()
}
// A write scope grants its own read subset, e.g. auth_keys ⊇ auth_keys:read.
return string(want) == string(have)+readSuffix
}
// RequiresTags reports whether any scope obliges a credential to carry tags:
// devices:core and auth_keys mint tagged, tailnet-owned credentials.
func RequiresTags(scopes []Scope) bool {
for _, s := range scopes {
if s == DevicesCore || s == AuthKeys {
return true
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
package scope
import (
"slices"
"testing"
"pgregory.net/rapid"
)
// scopeGen draws a scope: mostly from the real vocabulary, sometimes adversarial
// junk (including read-like junk such as "foo:read") so the rules are exercised
// against unknown input too.
func scopeGen() *rapid.Generator[Scope] {
known := Known()
return rapid.Custom(func(t *rapid.T) Scope {
if rapid.Float64().Draw(t, "junkP") < 0.2 {
return Scope(rapid.StringMatching(`[a-z_]{1,12}(:read)?`).Draw(t, "junk"))
}
return rapid.SampledFrom(known).Draw(t, "vocab")
})
}
// TestGrantsMatchesOracle fuzzes Grants against the independent oracle over random
// granted-sets and want-scopes (vocabulary + junk).
func TestGrantsMatchesOracle(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted")
want := scopeGen().Draw(rt, "want")
if got, exp := Grants(granted, want), oracleGrants(granted, want); got != exp {
rt.Fatalf("Grants(%v, %q) = %v, oracle = %v", granted, want, got, exp)
}
})
}
// TestGrantsInvariants asserts the algebraic properties of the grant relation hold
// for arbitrary inputs.
func TestGrantsInvariants(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted")
want := scopeGen().Draw(rt, "want")
// Reflexivity: a scope always grants itself.
if !Grants([]Scope{want}, want) {
rt.Fatalf("reflexivity: %q does not grant itself", want)
}
// The empty set grants nothing.
if Grants(nil, want) {
rt.Fatalf("empty grant satisfied %q", want)
}
// OR-semantics: a set grants iff some member does.
anyMember := slices.ContainsFunc(granted, func(g Scope) bool {
return Grants([]Scope{g}, want)
})
if Grants(granted, want) != anyMember {
rt.Fatalf("OR-semantics broken for %v / %q", granted, want)
}
// Monotonicity: adding a scope never withdraws a grant.
before := Grants(granted, want)
extra := scopeGen().Draw(rt, "extra")
after := Grants(append(slices.Clone(granted), extra), want)
if before && !after {
rt.Fatalf("monotonicity broken: adding %q withdrew the grant of %q", extra, want)
}
})
}
// TestSuperScopeProperties fuzzes the super-scope rules.
func TestSuperScopeProperties(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
want := scopeGen().Draw(rt, "want")
// all grants everything.
if !Grants([]Scope{All}, want) {
rt.Fatalf("all did not grant %q", want)
}
// all:read grants exactly the read scopes.
if Grants([]Scope{AllRead}, want) != want.IsRead() {
rt.Fatalf("all:read grant of %q = %v, want IsRead = %v",
want, Grants([]Scope{AllRead}, want), want.IsRead())
}
})
}
+234
View File
@@ -0,0 +1,234 @@
package scope
import (
"fmt"
"strings"
"testing"
)
// classify decomposes a scope into (resource, super, read) WITHOUT reusing any of
// the production logic, so the oracle below is an independent second
// implementation of the grant rule: a divergence between it and Grants is a real
// bug in one of them, not a tautology.
type classified struct {
resource string // "" for super-scopes; otherwise the write-scope base (e.g. "auth_keys")
super bool // all / all:read
read bool // the :read variant
}
func classify(s Scope) classified {
str := string(s)
read := strings.HasSuffix(str, ":read")
base := strings.TrimSuffix(str, ":read")
if base == "all" {
return classified{super: true, read: read}
}
return classified{resource: base, read: read}
}
// oracle re-derives "does have satisfy want" from the classification, independent
// of satisfies/Grants.
func oracle(have, want Scope) bool {
h, w := classify(have), classify(want)
if h.super {
// "all" grants everything; "all:read" grants only reads.
return !h.read || w.read
}
if h.resource != w.resource {
return false
}
// Same resource: a write scope grants both read and write; a read scope grants
// only read.
return !h.read || w.read
}
func oracleGrants(granted []Scope, want Scope) bool {
for _, g := range granted {
if oracle(g, want) {
return true
}
}
return false
}
// TestGrantsHandPicked pins specific (granted, want) outcomes with literal
// expected values, independent of any oracle: the anchor for the rules.
func TestGrantsHandPicked(t *testing.T) {
tests := []struct {
granted []Scope
want Scope
ok bool
}{
{granted: []Scope{AuthKeys}, want: AuthKeys, ok: true},
{granted: []Scope{AuthKeys}, want: AuthKeysRead, ok: true},
{granted: []Scope{AuthKeysRead}, want: AuthKeys, ok: false},
{granted: []Scope{AuthKeysRead}, want: AuthKeysRead, ok: true},
{granted: []Scope{DevicesCore}, want: AuthKeys, ok: false},
{granted: []Scope{DevicesCoreRead}, want: AuthKeysRead, ok: false},
{granted: []Scope{All}, want: AuthKeys, ok: true},
{granted: []Scope{All}, want: FeatureSettingsRead, ok: true},
{granted: []Scope{AllRead}, want: PolicyFileRead, ok: true},
{granted: []Scope{AllRead}, want: PolicyFile, ok: false},
{granted: []Scope{AllRead}, want: All, ok: false},
{granted: []Scope{DevicesCore, OAuthKeys}, want: OAuthKeys, ok: true},
{granted: nil, want: AuthKeysRead, ok: false},
{granted: []Scope{"garbage"}, want: AuthKeys, ok: false},
{granted: []Scope{"garbage"}, want: "garbage", ok: true},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%v_%s", tt.granted, tt.want), func(t *testing.T) {
if got := Grants(tt.granted, tt.want); got != tt.ok {
t.Errorf("Grants(%v, %q) = %v, want %v", tt.granted, tt.want, got, tt.ok)
}
})
}
}
// TestGrantsExhaustive checks every single-grant pair in the vocabulary against
// the independent oracle, plus representative multi-grant cases.
func TestGrantsExhaustive(t *testing.T) {
known := Known()
for _, g := range known {
for _, w := range known {
got := Grants([]Scope{g}, w)
exp := oracle(g, w)
if got != exp {
t.Errorf("Grants([%q], %q) = %v, oracle = %v", g, w, got, exp)
}
}
}
multi := [][]Scope{
{All, AuthKeysRead},
{AllRead, AuthKeys},
{AuthKeys, OAuthKeysRead},
{DevicesCore, DevicesRoutes, PolicyFile},
{AuthKeys, AuthKeys}, // duplicates
}
for _, granted := range multi {
for _, w := range known {
got := Grants(granted, w)
exp := oracleGrants(granted, w)
if got != exp {
t.Errorf("Grants(%v, %q) = %v, oracle = %v", granted, w, got, exp)
}
}
}
}
// TestWriteGrantsItsRead and friends assert the structural rules over the whole
// vocabulary, deterministically.
func TestWriteGrantsItsRead(t *testing.T) {
for _, s := range Known() {
if !s.IsWrite() {
continue
}
read := Scope(string(s) + ":read")
if !Grants([]Scope{s}, read) {
t.Errorf("write scope %q does not grant its read subset %q", s, read)
}
}
}
func TestReadNeverGrantsWrite(t *testing.T) {
for _, s := range Known() {
if !s.IsRead() {
continue
}
write := Scope(strings.TrimSuffix(string(s), ":read"))
if Grants([]Scope{s}, write) {
t.Errorf("read scope %q must not grant write scope %q", s, write)
}
}
}
func TestAllGrantsEverything(t *testing.T) {
for _, w := range Known() {
if !Grants([]Scope{All}, w) {
t.Errorf("all should grant %q", w)
}
}
}
func TestAllReadGrantsReadsOnly(t *testing.T) {
for _, w := range Known() {
got := Grants([]Scope{AllRead}, w)
if got != w.IsRead() {
t.Errorf("all:read grants %q = %v, want %v (IsRead)", w, got, w.IsRead())
}
}
}
// TestResourceIsolation: a non-super scope never grants a scope of a different
// resource.
func TestResourceIsolation(t *testing.T) {
for _, a := range Known() {
if a == All || a == AllRead {
continue
}
for _, b := range Known() {
if classify(a).resource == classify(b).resource {
continue
}
if Grants([]Scope{a}, b) {
t.Errorf("scope %q (resource %q) must not grant %q (resource %q)",
a, classify(a).resource, b, classify(b).resource)
}
}
}
}
func TestRequiresTags(t *testing.T) {
tests := []struct {
scopes []Scope
requires bool
}{
{scopes: []Scope{DevicesCore}, requires: true},
{scopes: []Scope{AuthKeys}, requires: true},
{scopes: []Scope{OAuthKeys}, requires: false},
{scopes: []Scope{PolicyFile, AuthKeys}, requires: true},
{scopes: []Scope{DevicesCoreRead}, requires: false},
{scopes: nil, requires: false},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%v", tt.scopes), func(t *testing.T) {
if got := RequiresTags(tt.scopes); got != tt.requires {
t.Errorf("RequiresTags(%v) = %v, want %v", tt.scopes, got, tt.requires)
}
})
}
}
func TestKnownIsComplete(t *testing.T) {
known := Known()
seen := make(map[Scope]bool, len(known))
for _, s := range known {
if seen[s] {
t.Errorf("Known() contains duplicate %q", s)
}
seen[s] = true
}
// 7 resources × 2 (write+read) + 2 super-scopes = 16.
if len(known) != 16 {
t.Errorf("Known() has %d scopes, want 16", len(known))
}
}
@@ -0,0 +1,391 @@
package servertest_test
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/stretchr/testify/require"
)
// TestAPIv2OAuthScopes proves the v2 OAuth scope and tag enforcement through the
// REAL Tailscale Terraform provider's client-credentials flow: the provider mints
// a scoped access token at /api/v2/oauth/token, then runs one operation whose
// allow/deny outcome must match the token's scope and tag grant.
//
// Each client is created with exactly the scopes/tags under test, and the
// provider requests no scope narrowing, so the minted token carries the client's
// full grant. The matrix covers right-scope-allowed, wrong-scope-denied, read vs
// write, the "all" super-scope, and policy tag ownership (owned-by delegation).
//
// The oauth_keys rows drive the real provider's tailscale_oauth_client resource,
// which omits the "capabilities" body field, so the request schema must make it
// optional (it is). Drift is not asserted on the auth-key rows: the provider
// defaults preauthorized=true on create but the server reads tagged keys back as
// false, so a converged plan is never empty. That is orthogonal to scope
// enforcement, so a clean apply is the allow proof.
//
// tofu is required, not optional: it ships in the nix dev shell, so a missing
// binary means a broken environment and the test fails rather than skipping.
func TestAPIv2OAuthScopes(t *testing.T) {
srv := servertest.NewServer(t, servertest.WithRealListener())
owner := srv.CreateUser(t, "apiv2-oauth")
creator := owner.ID
// scopeMatrixPolicy declares every tag the matrix touches: tag:ci for the
// auth-key rows, and the tag:k8s/tag:k8s-operator delegation for the
// owned-by rows.
setScopeMatrixPolicy(t, srv)
// A registered node is the target for the device-scope rows. Its decimal id
// is the device id the v2 API and the provider's device resources address.
// A devices:core write token also grants devices:core:read, so whatever
// get/post sequence the provider runs within the core family is satisfied;
// the read-scope row then fails on the write, proving read vs write end to
// end. devices:routes and feature_settings are proven exhaustively by the Go
// matrix (apiv2_oauth_matrix_test.go), not here: the provider's routes
// resource issues a cross-family device read a routes-only token would lack,
// and the server's settings endpoint is read-only.
deviceID := srv.CreateRegisteredNode(t, owner).StringID()
deviceAuthorizeConfig := `
resource "tailscale_device_authorization" "d" {
device_id = "` + deviceID + `"
authorized = true
}
`
// The configs each exercise exactly one operation so a row's allow/deny
// outcome is unambiguous.
const (
authKeyConfig = `
resource "tailscale_tailnet_key" "k" {
reusable = true
ephemeral = false
expiry = 3600
description = "oauth-scope-matrix"
tags = ["tag:ci"]
}
`
aclConfig = `
resource "tailscale_acl" "policy" {
acl = jsonencode({
tagOwners = {
"tag:ci" = ["apiv2-oauth@"]
"tag:k8s-operator" = []
"tag:k8s" = ["tag:k8s-operator"]
"tag:other" = []
}
acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }]
})
overwrite_existing_content = true
}
`
devicesReadConfig = `
data "tailscale_devices" "all" {}
output "device_count" { value = length(data.tailscale_devices.all.devices) }
`
// oauthClientConfig creates an OAuth client whose scope (oauth_keys:read)
// is within an oauth_keys grant, so the only variable under test is whether
// the caller may manage clients at all.
oauthClientConfig = `
resource "tailscale_oauth_client" "c" {
description = "oauth-scope-matrix-client"
scopes = ["oauth_keys:read"]
}
`
// oauthClientEscalateConfig requests a scope (devices:core:read) that an
// oauth_keys-only caller does not itself hold, so the escalation guard must
// reject minting a broader client.
oauthClientEscalateConfig = `
resource "tailscale_oauth_client" "c" {
description = "oauth-scope-matrix-escalate"
scopes = ["devices:core:read"]
}
`
)
// k8sAuthKeyConfig templates the auth-key tag for the owned-by rows.
k8sAuthKeyConfig := func(tag string) string {
return `
resource "tailscale_tailnet_key" "k" {
reusable = true
ephemeral = false
expiry = 3600
description = "oauth-scope-matrix"
tags = ["` + tag + `"]
}
`
}
tests := []struct {
name string
// scopes/tags the OAuth client (and thus its minted token) holds.
scopes []string
tags []string
// config is the single-operation HCL applied through the OAuth provider.
config string
// deny asserts apply fails; denyContains lists substrings, any of which
// the failure output must contain. allow asserts a clean apply.
deny bool
denyContains []string
}{
// auth_keys: write scope mints an auth key; read/wrong/all-read do not.
{
name: "auth_keys allows tailnet_key",
scopes: []string{"auth_keys"},
tags: []string{"tag:ci"},
config: authKeyConfig,
},
{
name: "auth_keys:read denies tailnet_key",
scopes: []string{"auth_keys:read"},
tags: []string{"tag:ci"},
config: authKeyConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
{
name: "devices:core denies tailnet_key",
scopes: []string{"devices:core"},
tags: []string{"tag:ci"},
config: authKeyConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
{
name: "all allows tailnet_key",
scopes: []string{"all"},
tags: []string{"tag:ci"},
config: authKeyConfig,
},
{
name: "all:read denies tailnet_key",
scopes: []string{"all:read"},
tags: []string{"tag:ci"},
config: authKeyConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
// oauth_keys: managing OAuth clients. Read and wrong scopes are denied, and
// a caller cannot mint a client carrying authority it lacks (escalation).
{
name: "oauth_keys allows oauth_client",
scopes: []string{"oauth_keys"},
config: oauthClientConfig,
},
{
name: "oauth_keys:read denies oauth_client",
scopes: []string{"oauth_keys:read"},
config: oauthClientConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
{
name: "auth_keys denies oauth_client",
scopes: []string{"auth_keys"},
tags: []string{"tag:ci"},
config: oauthClientConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
{
name: "oauth_keys cannot escalate client scope",
scopes: []string{"oauth_keys"},
config: oauthClientEscalateConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "beyond the creating token"},
},
// policy_file: write scope sets the ACL; read does not.
{
name: "policy_file allows acl",
scopes: []string{"policy_file"},
config: aclConfig,
},
{
name: "policy_file:read denies acl",
scopes: []string{"policy_file:read"},
config: aclConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
// devices:core:read reads the devices data source.
{
name: "devices:core:read allows devices read",
scopes: []string{"devices:core:read"},
config: devicesReadConfig,
},
{
name: "policy_file:read denies devices read",
scopes: []string{"policy_file:read"},
config: devicesReadConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
// devices:core: a write-scoped token authorizes a device; the read scope
// and a wrong-family scope are denied (read vs write through the real
// provider's tailscale_device_authorization resource).
{
name: "devices:core allows device authorize",
scopes: []string{"devices:core"},
tags: []string{"tag:ci"},
config: deviceAuthorizeConfig,
},
{
name: "devices:core:read denies device authorize",
scopes: []string{"devices:core:read"},
config: deviceAuthorizeConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
{
name: "policy_file denies device authorize",
scopes: []string{"policy_file"},
config: deviceAuthorizeConfig,
deny: true,
denyContains: []string{"403", "Forbidden", "missing the required scope"},
},
// Tag owned-by: a tag:k8s-operator client may use its own tag and the
// tag:k8s it owns, but not an unowned tag.
{
name: "owned-by exact tag allowed",
scopes: []string{"auth_keys"},
tags: []string{"tag:k8s-operator"},
config: k8sAuthKeyConfig("tag:k8s-operator"),
},
{
name: "owned-by delegated tag allowed",
scopes: []string{"auth_keys"},
tags: []string{"tag:k8s-operator"},
config: k8sAuthKeyConfig("tag:k8s"),
},
{
name: "owned-by unowned tag denied",
scopes: []string{"auth_keys"},
tags: []string{"tag:k8s-operator"},
config: k8sAuthKeyConfig("tag:other"),
deny: true,
denyContains: []string{"403", "Forbidden", "is not owned"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
secret, client, err := srv.State().CreateOAuthClient(tt.scopes, tt.tags, "scope-matrix", &creator)
require.NoError(t, err)
tf := newTofuOAuth(t, srv.URL, client.ClientID, secret, oauthHCL(tt.config))
tf.run("init", "-no-color", "-input=false")
if tt.deny {
tf.runExpectError(t, tt.denyContains...)
return
}
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
})
}
}
// setScopeMatrixPolicy installs the policy the scope matrix relies on: tag:ci for
// the auth-key rows, and the tag:k8s-operator → tag:k8s delegation for the
// owned-by rows.
func setScopeMatrixPolicy(t *testing.T, srv *servertest.TestServer) {
t.Helper()
// tag:other exists but is owned by no one, so the owned-by denial row tests a
// grant denial (403) rather than a tag-not-in-policy rejection (400).
const policy = `{"tagOwners":{"tag:ci":["apiv2-oauth@"],"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`
st := srv.State()
_, err := st.SetPolicy([]byte(policy))
require.NoError(t, err)
_, err = st.SetPolicyInDB(policy)
require.NoError(t, err)
_, err = st.ReloadPolicy()
require.NoError(t, err)
}
// oauthHCL wraps a single-operation body with the provider block. The provider
// authenticates via the OAuth env vars newTofuOAuth sets, so the block is empty.
func oauthHCL(body string) string {
return `
terraform {
required_providers {
tailscale = {
source = "tailscale/tailscale"
version = "~> 0.21"
}
}
}
provider "tailscale" {}
` + body
}
// newTofuOAuth is a newTofu variant whose provider authenticates with OAuth
// client credentials instead of an API key: it sets TAILSCALE_OAUTH_CLIENT_ID /
// TAILSCALE_OAUTH_CLIENT_SECRET (the env vars the tailscale/tailscale provider
// honors), so the real provider runs the client-credentials grant against
// baseURL/api/v2/oauth/token. The client id is embedded in the secret, so the
// secret embeds the client id (the provider sends both; the server derives the
// client from the secret alone).
func newTofuOAuth(t *testing.T, baseURL, clientID, clientSecret, config string) *tofu {
t.Helper()
bin, err := exec.LookPath("tofu")
require.NoErrorf(t, err, "tofu is required for TestAPIv2OAuthScopes (provided by the nix dev shell)")
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755))
env := append(
os.Environ(),
"TAILSCALE_BASE_URL="+baseURL,
"TAILSCALE_OAUTH_CLIENT_ID="+clientID,
"TAILSCALE_OAUTH_CLIENT_SECRET="+clientSecret,
"TAILSCALE_TAILNET=-",
// Keep provider plugins inside the temp dir so the run is self-contained.
"TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"),
)
cmd := func(args ...string) *exec.Cmd {
c := exec.CommandContext(t.Context(), bin, args...)
c.Dir = dir
c.Env = env
return c
}
return &tofu{t: t, cmd: cmd}
}
// runExpectError runs apply expecting FAILURE, asserting the combined output
// contains at least one of mustContain. This is the deny half of every matrix
// row: a scoped token attempting an operation it lacks the scope (or tag) for.
func (tf *tofu) runExpectError(t *testing.T, mustContain ...string) {
t.Helper()
out, err := tf.cmd("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1").CombinedOutput()
require.Errorf(t, err, "expected apply to fail, but it succeeded:\n%s", out)
combined := string(out)
for _, want := range mustContain {
if strings.Contains(combined, want) {
return
}
}
t.Fatalf("apply failed but output contained none of %v:\n%s", mustContain, combined)
}
+57
View File
@@ -1511,6 +1511,63 @@ func (s *State) DeletePreAuthKey(id uint64) error {
return s.db.DeletePreAuthKey(id)
}
// CreateOAuthClient creates a new OAuth client-credentials client, returning the
// plaintext secret (shown once) and the stored client.
func (s *State) CreateOAuthClient(scopes, tags []string, description string, creatorUserID *uint) (string, *types.OAuthClient, error) {
return s.db.CreateOAuthClient(scopes, tags, description, creatorUserID)
}
// AuthenticateOAuthClient validates a client secret and returns the client.
func (s *State) AuthenticateOAuthClient(secret string) (*types.OAuthClient, error) {
return s.db.AuthenticateOAuthClient(secret)
}
// GetOAuthClientByClientID returns an OAuth client by its public client id.
func (s *State) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {
return s.db.GetOAuthClientByClientID(clientID)
}
// ListOAuthClients returns every OAuth client.
func (s *State) ListOAuthClients() ([]types.OAuthClient, error) {
return s.db.ListOAuthClients()
}
// RevokeOAuthClient deletes a client and the access tokens it issued.
func (s *State) RevokeOAuthClient(clientID string) error {
return s.db.RevokeOAuthClient(clientID)
}
// MintAccessToken stores a new scoped access token for an OAuth client.
func (s *State) MintAccessToken(clientID string, scopes, tags []string, expiration *time.Time) (string, *types.OAuthAccessToken, error) {
return s.db.MintAccessToken(clientID, scopes, tags, expiration)
}
// AuthenticateAccessToken validates a bearer access token and returns it with
// its granted scopes and tags.
func (s *State) AuthenticateAccessToken(token string) (*types.OAuthAccessToken, error) {
return s.db.AuthenticateAccessToken(token)
}
// TagOwnedByTags reports whether a credential holding ownerTags may apply tag,
// per the policy's tag-to-tag ownership. Used to authorise the tags an OAuth
// access token sets on the auth keys it mints.
func (s *State) TagOwnedByTags(tag string, ownerTags []string) bool {
return s.polMan.TagOwnedByTags(tag, ownerTags)
}
// TagExists reports whether tag is defined in the policy's tagOwners. Used to
// reject OAuth clients and auth keys carrying tags that no policy authorises,
// matching SetNodeTags.
func (s *State) TagExists(tag string) bool {
return s.polMan.TagExists(tag)
}
// DeleteExpiredAccessTokens hard-deletes OAuth access tokens that expired before
// cutoff, returning how many were removed.
func (s *State) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) {
return s.db.DeleteExpiredAccessTokens(cutoff)
}
// GetAuthCacheEntry retrieves a pending auth request from the cache.
func (s *State) GetAuthCacheEntry(id types.AuthID) (*types.AuthRequest, bool) {
return s.authCache.Get(id)
+35 -4
View File
@@ -33,6 +33,9 @@ const (
var (
errOidcMutuallyExclusive = errors.New("oidc_client_secret and oidc_client_secret_path are mutually exclusive")
errOIDCIssuerInvalid = errors.New("oidc.issuer must be a valid http(s) URL")
errOIDCClientIDRequired = errors.New("oidc.client_id is required when oidc.issuer is set")
errOIDCClientSecretRequired = errors.New("oidc.client_secret or oidc.client_secret_path is required when oidc.issuer is set")
errServerURLSuffix = errors.New("server_url cannot be part of base_domain in a way that could make the DERP and headscale server unreachable")
errServerURLSame = errors.New("server_url cannot use the same domain as base_domain in a way that could make the DERP and headscale server unreachable")
errInvalidPKCEMethod = errors.New("pkce.method must be either 'plain' or 'S256'")
@@ -363,6 +366,35 @@ func validatePKCEMethod(method string) error {
return nil
}
// validateOIDCConfig validates the OIDC settings, called when oidc.issuer is
// set. It fails fast on a setup that cannot work: an invalid PKCE method, a
// malformed issuer URL (which would otherwise surface as an opaque discovery
// error or, worse, resolve to an unintended provider), or a missing client
// id/secret.
func validateOIDCConfig() error {
err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
if err != nil {
return err
}
issuer := viper.GetString("oidc.issuer")
u, err := url.Parse(issuer)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return fmt.Errorf("%w: got %q", errOIDCIssuerInvalid, issuer)
}
if viper.GetString("oidc.client_id") == "" {
return errOIDCClientIDRequired
}
if viper.GetString("oidc.client_secret") == "" && viper.GetString("oidc.client_secret_path") == "" {
return errOIDCClientSecretRequired
}
return nil
}
// Domain returns the hostname/domain part of the [Config.ServerURL].
// If the [Config.ServerURL] is not a valid URL, it returns the [Config.BaseDomain].
func (c *Config) Domain() string {
@@ -564,11 +596,10 @@ func validateServerConfig() error {
depr.fatalIfSet("oidc.expiry", "node.expiry")
// OIDC is activated by setting oidc.issuer (see app.go), not by a
// dedicated oidc.enabled key. Gate PKCE method validation on the real
// activation condition so an invalid method fails at startup instead of
// silently disabling PKCE at runtime.
// dedicated oidc.enabled key. Gate validation on the real activation
// condition so a misconfiguration fails at startup.
if viper.GetString("oidc.issuer") != "" {
err := validatePKCEMethod(viper.GetString("oidc.pkce.method"))
err := validateOIDCConfig()
if err != nil {
return err
}
+67
View File
@@ -444,6 +444,73 @@ oidc:
assert.Contains(t, err.Error(), errInvalidPKCEMethod.Error())
}
// TestOIDCConfigValidation covers the issuer-URL and required-field checks that
// fail an unworkable OIDC setup fast at config load.
func TestOIDCConfigValidation(t *testing.T) {
tests := []struct {
name string
oidcBlock string
wantErr string
}{
{
name: "non-http issuer",
oidcBlock: `
issuer: ftp://idp.example.com
client_id: headscale
client_secret: sekret`,
wantErr: "valid http(s) URL",
},
{
name: "missing client_id",
oidcBlock: `
issuer: https://idp.example.com
client_secret: sekret`,
wantErr: "client_id is required",
},
{
name: "missing client_secret",
oidcBlock: `
issuer: https://idp.example.com
client_id: headscale`,
wantErr: "client_secret",
},
{
name: "valid",
oidcBlock: `
issuer: https://idp.example.com
client_id: headscale
client_secret: sekret`,
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
configYaml := []byte(`---
noise:
private_key_path: noise_private.key
server_url: http://127.0.0.1:8080
dns:
override_local_dns: false
oidc:` + tt.oidcBlock + "\n")
require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "config.yaml"), configYaml, 0o600))
require.NoError(t, LoadConfig(tmpDir, false))
err := validateServerConfig()
if tt.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
// OK
// server_url: headscale.com, base: clients.headscale.com
// server_url: headscale.com, base: headscale.net
+153
View File
@@ -0,0 +1,153 @@
package types
import (
"time"
"github.com/rs/zerolog"
)
const (
// OAuthClientPrefix prefixes an OAuth client secret:
// hskey-client-<clientID>-<secret>.
OAuthClientPrefix = "hskey-client-" //nolint:gosec // prefix, not a credential
// AccessTokenPrefix prefixes an OAuth access token:
// hskey-oauthtok-<prefix>-<secret>. The v2 auth middleware dispatches a
// scope-limited token from an all-access admin key on this prefix alone, so
// it is one canonical constant shared by the db and api layers.
AccessTokenPrefix = "hskey-oauthtok-" //nolint:gosec // prefix, not a credential
)
// OAuthClient is a long-lived OAuth 2.0 client-credentials principal. It mints
// short-lived [OAuthAccessToken]s limited to its Scopes and Tags. The secret is
// stored only as an Argon2id hash. ClientID is public and embedded in the secret
// string (hskey-client-<ClientID>-<secret>) so the token endpoint can derive it
// from the secret alone, matching Tailscale, where the client id is a substring
// of the client secret.
//
// An OAuth client is always tag/tailnet-scoped, never user-owned: the access
// tokens it mints, and the auth keys those tokens create, produce tagged nodes.
// UserID only records who created the client (informational), mirroring
// [APIKey].
type OAuthClient struct {
ID uint64 `gorm:"primary_key"`
ClientID string `gorm:"uniqueIndex"`
SecretHash []byte
// Scopes the client may grant. Tags the client may assign to access tokens
// (and, transitively, to the auth keys and nodes those tokens create).
Scopes []string `gorm:"serializer:json"`
Tags []string `gorm:"serializer:json"`
Description string
// UserID records who created the client. Kept as a plain column with no
// foreign key so an upgraded database matches a freshly-migrated one.
UserID *uint
CreatedAt *time.Time
Revoked *time.Time
}
// TableName pins the table name. GORM's naming strategy would otherwise render
// OAuthClient as "o_auth_clients" (it breaks the OAuth initialism), diverging
// from the hand-written migration DDL and schema.sql.
func (*OAuthClient) TableName() string { return "oauth_clients" }
// OAuthAccessToken is a short-lived bearer token minted by an [OAuthClient] via
// the client-credentials grant. It carries the scope/tag set granted at mint
// time (a subset of the issuing client's), is stored as an Argon2id hash of its
// secret, and authenticates v2 API requests as Authorization: Bearer.
type OAuthAccessToken struct {
ID uint64 `gorm:"primary_key"`
Prefix string `gorm:"uniqueIndex"`
Hash []byte
// ClientID links back to the issuing [OAuthClient].
ClientID string
Scopes []string `gorm:"serializer:json"`
Tags []string `gorm:"serializer:json"`
Expiration *time.Time
CreatedAt *time.Time
}
// TableName pins the table name (see [OAuthClient.TableName]).
func (*OAuthAccessToken) TableName() string { return "oauth_access_tokens" }
// maskedClientID returns the client id in masked form for safe logging.
// SECURITY: never log the secret or its hash.
func (c *OAuthClient) maskedClientID() string {
if c.ClientID != "" {
return OAuthClientPrefix + c.ClientID + "-***"
}
return ""
}
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
// SECURITY: intentionally does NOT log the secret or hash.
func (c *OAuthClient) MarshalZerologObject(e *zerolog.Event) {
if c == nil {
return
}
e.Uint64("oauth_client_id", c.ID)
if masked := c.maskedClientID(); masked != "" {
e.Str("oauth_client", masked)
}
if len(c.Scopes) > 0 {
e.Strs("oauth_client_scopes", c.Scopes)
}
if len(c.Tags) > 0 {
e.Strs("oauth_client_tags", c.Tags)
}
if c.Revoked != nil {
e.Time("oauth_client_revoked", *c.Revoked)
}
}
// maskedPrefix returns the token prefix in masked form for safe logging.
// SECURITY: never log the secret or its hash.
func (t *OAuthAccessToken) maskedPrefix() string {
if t.Prefix != "" {
return AccessTokenPrefix + t.Prefix + "-***"
}
return ""
}
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
// SECURITY: intentionally does NOT log the secret or hash.
func (t *OAuthAccessToken) MarshalZerologObject(e *zerolog.Event) {
if t == nil {
return
}
e.Uint64("oauth_token_id", t.ID)
if masked := t.maskedPrefix(); masked != "" {
e.Str("oauth_token", masked)
}
if t.ClientID != "" {
e.Str("oauth_token_client", t.ClientID)
}
if len(t.Scopes) > 0 {
e.Strs("oauth_token_scopes", t.Scopes)
}
if len(t.Tags) > 0 {
e.Strs("oauth_token_tags", t.Tags)
}
if t.Expiration != nil {
e.Time("oauth_token_expiration", *t.Expiration)
}
}
+126
View File
@@ -0,0 +1,126 @@
package integration
import (
"encoding/json"
"testing"
"time"
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/integrationutil"
"github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// cliOAuthClient is the JSON the `oauth-clients` commands emit (the v2 Key shape,
// only the fields the test asserts on).
type cliOAuthClient struct {
ID string `json:"id"`
Key string `json:"key"`
KeyType string `json:"keyType"`
Scopes []string `json:"scopes"`
Tags []string `json:"tags"`
Description string `json:"description"`
}
// TestOAuthClientCommand exercises the `headscale oauth-clients` CLI end to end:
// create (with scopes and tags) -> list (secret hidden) -> delete. The CLI talks
// to the v2 keys handler over the local unix socket, so this also proves the v2
// API is reachable over the socket with local trust.
func TestOAuthClientCommand(t *testing.T) {
IntegrationSkip(t)
scenario, err := NewScenario(ScenarioSpec{Users: []string{}})
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-oauthclient"))
require.NoError(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
// Create an OAuth client with scopes and a tag. devices:core/auth_keys
// require a tag, which is supplied.
createOut, err := headscale.Execute([]string{
"headscale", "oauth-clients", "create",
"--scope", "auth_keys",
"--scope", "devices:core",
"--tag", "tag:k8s-operator",
"--description", "operator",
"--output", "json",
})
require.NoError(t, err)
var created cliOAuthClient
require.NoError(t, json.Unmarshal([]byte(createOut), &created))
assert.Equal(t, "client", created.KeyType)
assert.NotEmpty(t, created.ID, "client id returned")
assert.NotEmpty(t, created.Key, "secret returned once on create")
assert.ElementsMatch(t, []string{"auth_keys", "devices:core"}, created.Scopes)
assert.Equal(t, []string{"tag:k8s-operator"}, created.Tags)
// List shows the client without its secret.
var listed []cliOAuthClient
assert.EventuallyWithT(t, func(c *assert.CollectT) {
err := executeAndUnmarshal(headscale,
[]string{"headscale", "oauth-clients", "list", "--output", "json"}, &listed)
assert.NoError(c, err)
assert.Len(c, listed, 1)
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "waiting for oauth client list")
assert.Equal(t, created.ID, listed[0].ID)
assert.Empty(t, listed[0].Key, "secret is never exposed on list")
assert.ElementsMatch(t, []string{"auth_keys", "devices:core"}, listed[0].Scopes)
assert.Equal(t, "operator", listed[0].Description)
// Delete it.
_, err = headscale.Execute([]string{"headscale", "oauth-clients", "delete", "--id", created.ID})
require.NoError(t, err)
var afterDelete []cliOAuthClient
assert.EventuallyWithT(t, func(c *assert.CollectT) {
err := executeAndUnmarshal(headscale,
[]string{"headscale", "oauth-clients", "list", "--output", "json"}, &afterDelete)
assert.NoError(c, err)
assert.Empty(c, afterDelete)
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "waiting for oauth client list after delete")
}
// TestOAuthClientCommandValidation covers the CLI's input validation and the
// server's 404 on deleting an unknown client.
func TestOAuthClientCommandValidation(t *testing.T) {
IntegrationSkip(t)
scenario, err := NewScenario(ScenarioSpec{Users: []string{}})
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-oauthclientval"))
require.NoError(t, err)
headscale, err := scenario.Headscale()
require.NoError(t, err)
tests := []struct {
name string
args []string
wantErr string
}{
{name: "no scope", args: []string{"oauth-clients", "create"}, wantErr: "at least one --scope is required"},
{name: "devices:core needs tag", args: []string{"oauth-clients", "create", "--scope", "devices:core"}, wantErr: "tags are required"},
{name: "delete no id", args: []string{"oauth-clients", "delete"}, wantErr: "--id is required"},
{name: "delete nonexistent id", args: []string{"oauth-clients", "delete", "--id", "doesnotexist"}, wantErr: "404"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := headscale.Execute(append([]string{"headscale"}, tt.args...))
require.ErrorContains(t, err, tt.wantErr)
})
}
}