mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-05 04:59:26 +09:00
createKey has no static scope, so any token got its body parsed and validated before the handler's scope check.
622 lines
19 KiB
Go
622 lines
19 KiB
Go
package apiv2
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"slices"
|
|
"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,
|
|
Middlewares: huma.Middlewares{requireCreateKeyScope(api)},
|
|
MaxBodyBytes: maxBodyBytes,
|
|
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
|
|
}
|
|
|
|
// requireCreateKeyScope refuses, before huma reads the body, a token that may
|
|
// create neither kind of key; the handler then checks the kind it got.
|
|
func requireCreateKeyScope(api huma.API) func(huma.Context, func(huma.Context)) {
|
|
return func(ctx huma.Context, next func(huma.Context)) {
|
|
if requireKeyScope(ctx.Context(), scope.AuthKeys) != nil &&
|
|
requireKeyScope(ctx.Context(), scope.OAuthKeys) != nil {
|
|
_ = huma.WriteErr(api, ctx, http.StatusForbidden,
|
|
"token is missing the required scope auth_keys or oauth_keys")
|
|
|
|
return
|
|
}
|
|
|
|
next(ctx)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
// An untagged "all" client may mint tokens for any tag (narrowTags), so a
|
|
// tag-limited token would escape its tags one client away.
|
|
if len(tokenTags) > 0 && len(body.Tags) == 0 &&
|
|
slices.Contains(body.Scopes, string(scope.All)) {
|
|
return nil, huma.Error403Forbidden(
|
|
"client may not be granted scope all without tags by a tagged token",
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|