You've already forked step-ca-cli
mirror of
https://github.com/smallstep/cli.git
synced 2025-08-09 03:22:43 +03:00
Allow to add confirmation claims to tokens
This commit allows passing confirmation claims to tokens to tie the tokens with a provided CSR or SSH public key. The confirmation claim is implemented in the token command as well as the com commands that uses a given CSR or ssh public key. Those are: - step ca token - step ca sign - step ssh certificate --sign Fixes smallstep/certificates#1637
This commit is contained in:
@@ -175,7 +175,7 @@ func signCertificateAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// certificate flow unifies online and offline flows on a single api
|
||||
flow, err := cautils.NewCertificateFlow(ctx)
|
||||
flow, err := cautils.NewCertificateFlow(ctx, cautils.WithCertificateRequest(csr))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/smallstep/certificates/api"
|
||||
"github.com/smallstep/certificates/pki"
|
||||
"github.com/smallstep/cli/flags"
|
||||
@@ -12,6 +13,8 @@ import (
|
||||
"github.com/urfave/cli"
|
||||
"go.step.sm/cli-utils/command"
|
||||
"go.step.sm/cli-utils/errs"
|
||||
"go.step.sm/crypto/pemutil"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func tokenCommand() cli.Command {
|
||||
@@ -27,6 +30,7 @@ func tokenCommand() cli.Command {
|
||||
[**--output-file**=<file>] [**--kms**=uri] [**--key**=<file>] [**--san**=<SAN>] [**--offline**]
|
||||
[**--revoke**] [**--x5c-cert**=<file>] [**--x5c-key**=<file>] [**--x5c-insecure**]
|
||||
[**--sshpop-cert**=<file>] [**--sshpop-key**=<file>]
|
||||
[**--cnf-file**=<file>] [**--cnf-kid**=<fingerprint>]
|
||||
[**--ssh**] [**--host**] [**--principal**=<name>] [**--k8ssa-token-path**=<file>]
|
||||
[**--ca-url**=<uri>] [**--root**=<file>] [**--context**=<name>]`,
|
||||
Description: `**step ca token** command generates a one-time token granting access to the
|
||||
@@ -82,6 +86,11 @@ Get a new token that becomes valid in 30 minutes and expires 5 minutes after tha
|
||||
$ step ca token --not-before 30m --not-after 35m internal.example.com
|
||||
'''
|
||||
|
||||
Get a new token with a confirmation claim to enforce the use of a given CSR:
|
||||
'''
|
||||
step ca token --cnf-file internal.csr internal.smallstep.com
|
||||
'''
|
||||
|
||||
Get a new token signed with the given private key, the public key must be
|
||||
configured in the certificate authority:
|
||||
'''
|
||||
@@ -133,6 +142,11 @@ Get a new token for an SSH host certificate:
|
||||
$ step ca token my-remote.hostname --ssh --host
|
||||
'''
|
||||
|
||||
Get a new token with a confirmation claim to enforce the use of a given public key:
|
||||
'''
|
||||
step ca token --ssh --host --cnf-file internal.pub internal.smallstep.com
|
||||
'''
|
||||
|
||||
Generate a renew token and use it in a renew after expiry request:
|
||||
'''
|
||||
$ TOKEN=$(step ca token --x5c-cert internal.crt --x5c-key internal.key --renew internal.example.com)
|
||||
@@ -186,6 +200,8 @@ multiple principals.`,
|
||||
flags.SSHPOPKey,
|
||||
flags.NebulaCert,
|
||||
flags.NebulaKey,
|
||||
flags.ConfirmationFile,
|
||||
flags.ConfirmationKid,
|
||||
cli.StringFlag{
|
||||
Name: "key",
|
||||
Usage: `The private key <file> used to sign the JWT. This is usually downloaded from
|
||||
@@ -240,6 +256,9 @@ func tokenAction(ctx *cli.Context) error {
|
||||
isSSH := ctx.Bool("ssh")
|
||||
isHost := ctx.Bool("host")
|
||||
principals := ctx.StringSlice("principal")
|
||||
// confirmation claims
|
||||
cnfFile := ctx.String("cnf-file")
|
||||
cnfKid := ctx.String("cnf-kid")
|
||||
|
||||
switch {
|
||||
case isSSH && len(sans) > 0:
|
||||
@@ -252,6 +271,8 @@ func tokenAction(ctx *cli.Context) error {
|
||||
return errs.RequiredWithFlag(ctx, "host", "ssh")
|
||||
case !isSSH && len(principals) > 0:
|
||||
return errs.RequiredWithFlag(ctx, "principal", "ssh")
|
||||
case cnfFile != "" && cnfKid != "":
|
||||
return errs.IncompatibleFlagWithFlag(ctx, "cnf-file", "cnf-kid")
|
||||
}
|
||||
|
||||
// Default token type is always a 'Sign' token.
|
||||
@@ -295,6 +316,31 @@ func tokenAction(ctx *cli.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Add options to create a confirmation claim if a CSR or SSH public key is
|
||||
// passed.
|
||||
var tokenOpts []cautils.Option
|
||||
if cnfFile != "" {
|
||||
in, err := utils.ReadFile(cnfFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isSSH {
|
||||
sshPub, _, _, _, err := ssh.ParseAuthorizedKey(in)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error parsing ssh public key")
|
||||
}
|
||||
tokenOpts = append(tokenOpts, cautils.WithSSHPublicKey(sshPub))
|
||||
} else {
|
||||
csr, err := pemutil.ParseCertificateRequest(in)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error parsing certificate request")
|
||||
}
|
||||
tokenOpts = append(tokenOpts, cautils.WithCertificateRequest(csr))
|
||||
}
|
||||
} else if cnfKid != "" {
|
||||
tokenOpts = append(tokenOpts, cautils.WithConfirmationKid(cnfKid))
|
||||
}
|
||||
|
||||
// --san and --type revoke are incompatible. Revocation tokens do not support SANs.
|
||||
if typ == cautils.RevokeType && len(sans) > 0 {
|
||||
return errs.IncompatibleFlagWithFlag(ctx, "san", "revoke")
|
||||
@@ -327,7 +373,7 @@ func tokenAction(ctx *cli.Context) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter)
|
||||
token, err = cautils.NewTokenFlow(ctx, typ, subject, sans, caURL, root, notBefore, notAfter, certNotBefore, certNotAfter, tokenOpts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@@ -267,7 +267,41 @@ func certificateAction(ctx *cli.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
flow, err := cautils.NewCertificateFlow(ctx)
|
||||
var (
|
||||
sshPub ssh.PublicKey
|
||||
pub, priv interface{}
|
||||
flowOptions []cautils.Option
|
||||
)
|
||||
|
||||
if isSign {
|
||||
in, err := utils.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshPub, _, _, _, err = ssh.ParseAuthorizedKey(in)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error parsing ssh public key")
|
||||
}
|
||||
if len(sshPrivKeyFile) > 0 {
|
||||
if priv, err = pemutil.Read(sshPrivKeyFile); err != nil {
|
||||
return errors.Wrap(err, "error parsing private key")
|
||||
}
|
||||
}
|
||||
flowOptions = append(flowOptions, cautils.WithSSHPublicKey(sshPub))
|
||||
} else {
|
||||
pub, priv, err = keyutil.GenerateDefaultKeyPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshPub, err = ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error creating public key")
|
||||
}
|
||||
}
|
||||
|
||||
flow, err := cautils.NewCertificateFlow(ctx, flowOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -353,38 +387,6 @@ func certificateAction(ctx *cli.Context) error {
|
||||
identityKey = key
|
||||
}
|
||||
|
||||
var sshPub ssh.PublicKey
|
||||
var pub, priv interface{}
|
||||
|
||||
if isSign {
|
||||
// Use public key supplied as input.
|
||||
in, err := utils.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshPub, _, _, _, err = ssh.ParseAuthorizedKey(in)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error parsing ssh public key")
|
||||
}
|
||||
if len(sshPrivKeyFile) > 0 {
|
||||
if priv, err = pemutil.Read(sshPrivKeyFile); err != nil {
|
||||
return errors.Wrap(err, "error parsing private key")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generate keypair
|
||||
pub, priv, err = keyutil.GenerateDefaultKeyPair()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sshPub, err = ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error creating public key")
|
||||
}
|
||||
}
|
||||
|
||||
var sshAuPub ssh.PublicKey
|
||||
var sshAuPubBytes []byte
|
||||
var auPub, auPriv interface{}
|
||||
|
@@ -379,6 +379,21 @@ be stored in the 'sshpop' header.`,
|
||||
be stored in the 'nebula' header.`,
|
||||
}
|
||||
|
||||
// ConfirmationFile is a cli.Flag used to add a confirmation claim in the
|
||||
// tokens. It will add a confirmation kid with the fingerprint of the CSR or
|
||||
// an SSH public key.
|
||||
ConfirmationFile = cli.StringFlag{
|
||||
Name: "cnf-file",
|
||||
Usage: `The CSR or SSH public key <file> to restrict this token for.`,
|
||||
}
|
||||
|
||||
// ConfirmationKid is a cli.Flag used to add a confirmation claim in the
|
||||
// token.
|
||||
ConfirmationKid = cli.StringFlag{
|
||||
Name: "cnf-kid",
|
||||
Usage: `The <fingerprint> of the CSR or SSH public key to restrict this token for.`,
|
||||
}
|
||||
|
||||
// Team is a cli.Flag used to pass the team ID.
|
||||
Team = cli.StringFlag{
|
||||
Name: "team",
|
||||
|
@@ -2,6 +2,7 @@ package token
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/ecdh"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
@@ -15,9 +16,11 @@ import (
|
||||
|
||||
"github.com/pkg/errors"
|
||||
nebula "github.com/slackhq/nebula/cert"
|
||||
"go.step.sm/crypto/fingerprint"
|
||||
"go.step.sm/crypto/jose"
|
||||
"go.step.sm/crypto/pemutil"
|
||||
"go.step.sm/crypto/x25519"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Options is a function that set claims.
|
||||
@@ -84,6 +87,43 @@ func WithSSH(v interface{}) Options {
|
||||
})
|
||||
}
|
||||
|
||||
// WithConfirmationKid returns an Options function that sets the cnf claim with
|
||||
// the given kid.
|
||||
func WithConfirmationKid(kid string) Options {
|
||||
return func(c *Claims) error {
|
||||
c.Set(ConfirmationClaim, map[string]string{
|
||||
"kid": kid,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithFingerprint returns an Options function that the cnf claims with the kid
|
||||
// representing the fingerprint of the certificate request or the ssh public
|
||||
// key.
|
||||
func WithFingerprint(v interface{}) Options {
|
||||
return func(c *Claims) error {
|
||||
var data []byte
|
||||
switch vv := v.(type) {
|
||||
case *x509.CertificateRequest:
|
||||
data = vv.Raw
|
||||
case ssh.PublicKey:
|
||||
data = vv.Marshal()
|
||||
default:
|
||||
return fmt.Errorf("unsupported fingerprint for %T", vv)
|
||||
}
|
||||
|
||||
kid, err := fingerprint.New(data, crypto.SHA256, fingerprint.Base64RawURLFingerprint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Set(ConfirmationClaim, map[string]string{
|
||||
"kid": kid,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithValidity validates boundary inputs and sets the 'nbf' (NotBefore) and
|
||||
// 'exp' (expiration) options.
|
||||
func WithValidity(notBefore, expiration time.Time) Options {
|
||||
|
@@ -16,7 +16,9 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.step.sm/crypto/jose"
|
||||
"go.step.sm/crypto/pemutil"
|
||||
"go.step.sm/crypto/x25519"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func TestOptions(t *testing.T) {
|
||||
@@ -35,6 +37,11 @@ func TestOptions(t *testing.T) {
|
||||
p256ECDHSigner, err := p256Signer.ECDH()
|
||||
require.NoError(t, err)
|
||||
|
||||
testCSR, err := pemutil.ReadCertificateRequest("testdata/test.csr")
|
||||
require.NoError(t, err)
|
||||
|
||||
testSSH := mustReadSSHPublicKey(t, "testdata/ssh-key.pub")
|
||||
|
||||
wrongNebulaContentsFilename := "testdata/ca.crt"
|
||||
|
||||
emptyFile, err := os.CreateTemp(tempDir, "empty-file")
|
||||
@@ -79,6 +86,10 @@ func TestOptions(t *testing.T) {
|
||||
{"WithNebulaCurve25519Cert empty file fail", WithNebulaCert(emptyFile.Name(), nil), empty, true},
|
||||
{"WithNebulaCurve25519Cert invalid content fail", WithNebulaCert(c25519CertFilename, nil), empty, true},
|
||||
{"WithNebulaCurve25519Cert mismatching key fail", WithNebulaCert(c25519CertFilename, p256Signer), empty, true},
|
||||
{"WithConfirmationKid ok", WithConfirmationKid("my-kid"), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "my-kid"}}}, false},
|
||||
{"WithFingerprint csr ok", WithFingerprint(testCSR), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "ak6j6CwuZbd_mOQ-pNOUwhpmtSN0mY0xrLvaQL4J5l8"}}}, false},
|
||||
{"WithFingerprint ssh ok", WithFingerprint(testSSH), &Claims{ExtraClaims: map[string]any{"cnf": map[string]string{"kid": "hpTQOoB7fIRxTp-FhXCIm94mGBv7_dzr_5SxLn1Pnwk"}}}, false},
|
||||
{"WithFingerprint fail", WithFingerprint("unexpected type"), empty, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -96,6 +107,18 @@ func TestOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadSSHPublicKey(t *testing.T, filename string) ssh.PublicKey {
|
||||
t.Helper()
|
||||
|
||||
b, err := os.ReadFile(filename)
|
||||
require.NoError(t, err)
|
||||
|
||||
pub, _, _, _, err := ssh.ParseAuthorizedKey(b)
|
||||
require.NoError(t, err)
|
||||
|
||||
return pub
|
||||
}
|
||||
|
||||
func serializeAndWriteNebulaCert(t *testing.T, tempDir string, cert *nebula.NebulaCertificate) (string, []byte) {
|
||||
file, err := os.CreateTemp(tempDir, "nebula-test-cert-*")
|
||||
require.NoError(t, err)
|
||||
|
1
token/testdata/ssh-key.pub
vendored
Normal file
1
token/testdata/ssh-key.pub
vendored
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF14RP3HJkO1yoZHjo9t/4bJgyJGiSPxhm6FApa3VtG1 mariano@overlook.local
|
8
token/testdata/test.csr
vendored
Normal file
8
token/testdata/test.csr
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
-----BEGIN CERTIFICATE REQUEST-----
|
||||
MIIBBDCBqwIBADAbMRkwFwYDVQQDDBB0ZXN0QGV4YW1wbGUuY29tMFkwEwYHKoZI
|
||||
zj0CAQYIKoZIzj0DAQcDQgAEPj0tlICeGPiz361yM+AGlZmDK+N/cT0SVloozOQH
|
||||
1ljdNbookliEX8eRnFnelZRaql1KhrVOXhfwBmd/eGhti6AuMCwGCSqGSIb3DQEJ
|
||||
DjEfMB0wGwYDVR0RBBQwEoEQdGVzdEBleGFtcGxlLmNvbTAKBggqhkjOPQQDAgNI
|
||||
ADBFAiEA4WuukEVIFJQHNqlZVsWtsWsSVLNRCxBBJfH7/+txNw4CIGyK3eo5MDvR
|
||||
DepPHVRF16/b+iW/4HgAgIC90+5Q4IrL
|
||||
-----END CERTIFICATE REQUEST-----
|
@@ -32,6 +32,10 @@ const SANSClaim = "sans"
|
||||
// StepClaim is the property name for a JWT claim the stores the custom information in the certificate.
|
||||
const StepClaim = "step"
|
||||
|
||||
// ConfirmationClaim is the property name for a JWT claim that stores a JSON
|
||||
// object used as Proof-Of-Possession.
|
||||
const ConfirmationClaim = "cnf"
|
||||
|
||||
// Token interface which all token types should attempt to implement.
|
||||
type Token interface {
|
||||
SignedString(sigAlg string, priv interface{}) (string, error)
|
||||
|
@@ -27,6 +27,7 @@ import (
|
||||
"go.step.sm/crypto/keyutil"
|
||||
"go.step.sm/crypto/pemutil"
|
||||
"go.step.sm/crypto/x509util"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// CertificateFlow manages the flow to retrieve a new certificate.
|
||||
@@ -35,16 +36,65 @@ type CertificateFlow struct {
|
||||
offline bool
|
||||
}
|
||||
|
||||
type flowContext struct {
|
||||
DisableCustomSANs bool
|
||||
SSHPublicKey ssh.PublicKey
|
||||
CertificateRequest *x509.CertificateRequest
|
||||
ConfirmationKid string
|
||||
}
|
||||
|
||||
// sharedContext is used to share information between commands.
|
||||
var sharedContext = struct {
|
||||
DisableCustomSANs bool
|
||||
}{}
|
||||
var sharedContext flowContext
|
||||
|
||||
type funcFlowOption struct {
|
||||
f func(fo *flowContext)
|
||||
}
|
||||
|
||||
func (ffo *funcFlowOption) apply(fo *flowContext) {
|
||||
ffo.f(fo)
|
||||
}
|
||||
|
||||
func newFuncFlowOption(f func(fo *flowContext)) *funcFlowOption {
|
||||
return &funcFlowOption{
|
||||
f: f,
|
||||
}
|
||||
}
|
||||
|
||||
type Option interface {
|
||||
apply(fo *flowContext)
|
||||
}
|
||||
|
||||
// WithSSHPublicKey sets the SSH public key used in the request.
|
||||
func WithSSHPublicKey(key ssh.PublicKey) Option {
|
||||
return newFuncFlowOption(func(fo *flowContext) {
|
||||
fo.SSHPublicKey = key
|
||||
})
|
||||
}
|
||||
|
||||
// WithCertificateRequest sets the X509 certificate request used in the request.
|
||||
func WithCertificateRequest(cr *x509.CertificateRequest) Option {
|
||||
return newFuncFlowOption(func(fo *flowContext) {
|
||||
fo.CertificateRequest = cr
|
||||
})
|
||||
}
|
||||
|
||||
// WithConfirmationKid sets the confirmation kid used in the request.
|
||||
func WithConfirmationKid(kid string) Option {
|
||||
return newFuncFlowOption(func(fo *flowContext) {
|
||||
fo.ConfirmationKid = kid
|
||||
})
|
||||
}
|
||||
|
||||
// NewCertificateFlow initializes a cli flow to get a new certificate.
|
||||
func NewCertificateFlow(ctx *cli.Context) (*CertificateFlow, error) {
|
||||
func NewCertificateFlow(ctx *cli.Context, opts ...Option) (*CertificateFlow, error) {
|
||||
var err error
|
||||
var offlineClient *OfflineCA
|
||||
|
||||
// Add options to the shared context
|
||||
for _, opt := range opts {
|
||||
opt.apply(&sharedContext)
|
||||
}
|
||||
|
||||
offline := ctx.Bool("offline")
|
||||
if offline {
|
||||
caConfig := ctx.String("ca-config")
|
||||
|
@@ -85,7 +85,12 @@ func (e *ACMETokenError) Error() string {
|
||||
}
|
||||
|
||||
// NewTokenFlow implements the common flow used to generate a token
|
||||
func NewTokenFlow(ctx *cli.Context, tokType int, subject string, sans []string, caURL, root string, notBefore, notAfter time.Time, certNotBefore, certNotAfter provisioner.TimeDuration) (string, error) {
|
||||
func NewTokenFlow(ctx *cli.Context, tokType int, subject string, sans []string, caURL, root string, notBefore, notAfter time.Time, certNotBefore, certNotAfter provisioner.TimeDuration, opts ...Option) (string, error) {
|
||||
// Apply options to shared context
|
||||
for _, opt := range opts {
|
||||
opt.apply(&sharedContext)
|
||||
}
|
||||
|
||||
// Get audience from ca-url
|
||||
audience, err := parseAudience(ctx, tokType)
|
||||
if err != nil {
|
||||
|
@@ -98,6 +98,14 @@ func (t *TokenGenerator) SignToken(sub string, sans []string, opts ...token.Opti
|
||||
sans = []string{sub}
|
||||
}
|
||||
opts = append(opts, token.WithSANS(sans))
|
||||
|
||||
// Tie certificate request to the token used in the JWK and X5C provisioners
|
||||
if sharedContext.CertificateRequest != nil {
|
||||
opts = append(opts, token.WithFingerprint(sharedContext.CertificateRequest))
|
||||
} else if sharedContext.ConfirmationKid != "" {
|
||||
opts = append(opts, token.WithConfirmationKid(sharedContext.ConfirmationKid))
|
||||
}
|
||||
|
||||
return t.Token(sub, opts...)
|
||||
}
|
||||
|
||||
@@ -115,6 +123,14 @@ func (t *TokenGenerator) SignSSHToken(sub, certType string, principals []string,
|
||||
ValidAfter: notBefore,
|
||||
ValidBefore: notAfter,
|
||||
})}, opts...)
|
||||
|
||||
// Tie SSH public key to the token used in the JWK and X5C provisioners
|
||||
if sharedContext.SSHPublicKey != nil {
|
||||
opts = append(opts, token.WithFingerprint(sharedContext.SSHPublicKey))
|
||||
} else if sharedContext.ConfirmationKid != "" {
|
||||
opts = append(opts, token.WithConfirmationKid(sharedContext.ConfirmationKid))
|
||||
}
|
||||
|
||||
return t.Token(sub, opts...)
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user