Files

594 lines
18 KiB
Go

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() {
registrations = append(registrations, registerKeys)
}
// defaultExpiry is the auth-key lifetime when the request omits expirySeconds:
// 90 days, matching Tailscale and the Terraform provider default.
const defaultExpiry = 90 * 24 * time.Hour
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
// anonymous nesting) give Huma stable schema names.
type KeyCapabilities struct {
Devices KeyDeviceCapabilities `json:"devices"`
}
type KeyDeviceCapabilities struct {
Create KeyDeviceCreateCapabilities `json:"create"`
}
type KeyDeviceCreateCapabilities struct {
Reusable bool `json:"reusable"`
Ephemeral bool `json:"ephemeral"`
Preauthorized bool `json:"preauthorized"`
// Tags is not nullable:"false": the Tailscale clients send "tags":null for
// an untagged key, which must be accepted. The response always emits [] via
// emptyIfNil.
Tags []string `json:"tags"`
}
// 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 {
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 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"`
Key string `json:"key,omitempty"`
Description string `json:"description,omitempty"`
ExpirySeconds int64 `json:"expirySeconds,omitempty"`
Created time.Time `json:"created"`
Expires *time.Time `json:"expires,omitempty"`
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"`
}
type (
createKeyInput struct {
Tailnet string `doc:"Tailnet; must be \"-\" (the single Headscale tailnet)." path:"tailnet"`
Body CreateKeyRequest
}
listKeysInput struct {
Tailnet string `path:"tailnet"`
All bool `doc:"Accepted for compatibility; Headscale returns all keys." query:"all"`
}
keyByIDInput struct {
Tailnet string `path:"tailnet"`
KeyID string `path:"keyId"`
}
keyOutput struct {
Body Key
}
listKeysOutput struct {
Body struct {
Keys []Key `json:"keys" nullable:"false"`
}
}
deleteKeyOutput struct {
Body struct{}
}
)
// 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, huma.Operation{
OperationID: "createKey",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/keys",
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{
http.StatusBadRequest,
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, func(ctx context.Context, in *createKeyInput) (*keyOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
if in.Body.KeyType == keyTypeClient {
return createOAuthClient(ctx, b, in.Body)
}
return createAuthKey(ctx, b, in.Body)
})
huma.Register(api, huma.Operation{
OperationID: "listKeys",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/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{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, func(ctx context.Context, in *listKeysInput) (*listKeysOutput, error) {
err := requireDefaultTailnet(in.Tailnet)
if err != nil {
return nil, err
}
scopes, isOAuth := principalScopes(ctx)
out := &listKeysOutput{}
out.Body.Keys = []Key{}
// 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, huma.Operation{
OperationID: "getKey",
Method: http.MethodGet,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
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{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, 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
}
return &keyOutput{Body: keyFromStored(key)}, nil
})
huma.Register(api, huma.Operation{
OperationID: "deleteKey",
Method: http.MethodDelete,
Path: "/api/v2/tailnet/{tailnet}/keys/{keyId}",
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,
Errors: []int{
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
},
}, 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
}
// Tailscale's DELETE revokes the key but keeps it retrievable (invalid)
// rather than destroying it; the collector reaps it after the retention
// window.
err = b.State.RevokePreAuthKey(id)
if err != nil {
return nil, mapError("revoking auth key", err)
}
return &deleteKeyOutput{}, nil
})
}
// 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.
func findKeyByID(b Backend, rawID string) (*types.PreAuthKey, error) {
id, err := parseID(rawID, "auth key")
if err != nil {
return nil, err
}
pak, err := b.State.GetPreAuthKeyByID(id)
if err != nil {
return nil, mapError("looking up auth key", err)
}
return pak, nil
}
// keyFromNew builds the create response from the freshly created key. The
// plaintext secret is returned only here. preauthorized is reported true to
// 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 {
var create KeyDeviceCreateCapabilities
if req.Capabilities != nil {
create = req.Capabilities.Devices.Create
}
key := Key{
ID: pak.StringID(),
KeyType: keyTypeAuth,
Key: pak.Key,
Description: req.Description,
Created: timeOrZero(pak.CreatedAt),
Capabilities: capabilities(
create.Reusable,
create.Ephemeral,
true,
pak.Tags,
),
Tags: pak.Tags,
}
if pak.Expiration != nil {
key.Expires = pak.Expiration
key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration)
}
// A tagged key presents no owner; a user-owned key reports its user id.
if len(pak.Tags) == 0 && pak.User != nil {
key.UserID = pak.User.StringID()
}
return key
}
// keyFromStored builds the get/list response from a stored key. The secret is
// never returned here. preauthorized is reported true: Headscale has no separate
// device-approval step, so every pre-auth key authorizes its nodes. Reporting it
// stably keeps the Terraform provider from seeing a forced-replacement diff.
func keyFromStored(pak *types.PreAuthKey) Key {
key := Key{
ID: pak.StringID(),
KeyType: keyTypeAuth,
Description: pak.Description,
Created: timeOrZero(pak.CreatedAt),
Invalid: pak.Validate() != nil,
Capabilities: capabilities(pak.Reusable, pak.Ephemeral, true, pak.Tags),
Tags: pak.Tags,
}
if pak.Expiration != nil {
key.Expires = pak.Expiration
key.ExpirySeconds = expirySeconds(pak.CreatedAt, pak.Expiration)
}
if pak.Revoked != nil {
key.Revoked = pak.Revoked
}
if len(pak.Tags) == 0 && pak.User != nil {
key.UserID = pak.User.StringID()
}
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,
) KeyCapabilities {
return KeyCapabilities{
Devices: KeyDeviceCapabilities{Create: KeyDeviceCreateCapabilities{
Reusable: reusable,
Ephemeral: ephemeral,
Preauthorized: preauthorized,
Tags: emptyIfNil(tags),
}},
}
}
func expiryDuration(seconds int64) time.Duration {
if seconds <= 0 {
return defaultExpiry
}
return time.Duration(seconds) * time.Second
}