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