Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions autocomplete/fish_autocomplete
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ complete -c lk -n '__fish_seen_subcommand_from create' -f -l allow-source -r -d
complete -c lk -n '__fish_seen_subcommand_from create' -f -l identity -s i -r -d 'Unique `ID` of the participant, used with --join'
complete -c lk -n '__fish_seen_subcommand_from create' -f -l name -s n -r -d '`NAME` of the participant, used with --join. defaults to identity'
complete -c lk -n '__fish_seen_subcommand_from create' -f -l room -s r -r -d '`NAME` of the room to join'
complete -c lk -n '__fish_seen_subcommand_from create' -f -l json -s j -d 'Output as JSON'
complete -c lk -n '__fish_seen_subcommand_from create' -f -l token-only -d 'Output only the access token'
complete -c lk -n '__fish_seen_subcommand_from create' -f -l metadata -r -d '`JSON` metadata to encode in the token, will be passed to participant'
complete -c lk -n '__fish_seen_subcommand_from create' -f -l attribute -r -d 'set attributes in key=value format, can be used multiple times'
complete -c lk -n '__fish_seen_subcommand_from create' -l attribute-file -r -d 'read attributes from a `JSON` file'
Expand All @@ -315,6 +317,8 @@ complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l allow-source
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l identity -s i -r -d 'Unique `ID` of the participant, used with --join'
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l name -s n -r -d '`NAME` of the participant, used with --join. defaults to identity'
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l room -s r -r -d '`NAME` of the room to join'
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l json -s j -d 'Output as JSON'
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l token-only -d 'Output only the access token'
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l room-configuration -r -d 'name of the room configuration to use when creating a room'
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l metadata -r -d '`JSON` metadata to encode in the token, will be passed to participant'
complete -c lk -n '__fish_seen_subcommand_from create-token' -f -l attribute -r -d 'set attributes in key=value format, can be used multiple times'
Expand Down
90 changes: 75 additions & 15 deletions cmd/lk/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"slices"
"time"
Expand All @@ -33,17 +34,30 @@ import (
)

const (
usageCreate = "Ability to create or delete rooms"
usageList = "Ability to list rooms"
usageJoin = "Ability to join a room (requires --room and --identity)"
usageAdmin = "Ability to moderate a room (requires --room)"
usageEgress = "Ability to interact with Egress services"
usageIngress = "Ability to interact with Ingress services"
usageCreate = "Ability to create or delete rooms"
usageList = "Ability to list rooms"
usageJoin = "Ability to join a room (requires --room and --identity)"
usageAdmin = "Ability to moderate a room (requires --room)"
usageEgress = "Ability to interact with Egress services"
usageIngress = "Ability to interact with Ingress services"
usageMetadata = "Ability to update their own name and metadata"
usageInference = "Ability to perform inference (AI endpoints)"
)

var (
tokenOnlyFlag = &cli.BoolFlag{
Name: "token-only",
Usage: "Output only the access token",
}

tokenOutputMutuallyExclusiveFlags = []cli.MutuallyExclusiveFlags{{
Flags: [][]cli.Flag{{
jsonFlag,
}, {
tokenOnlyFlag,
}},
}}

TokenCommands = []*cli.Command{
{
Name: "token",
Expand Down Expand Up @@ -131,6 +145,7 @@ var (
Usage: "Metadata attached to job dispatched to the agent (ctx.job.metadata)",
},
},
MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags,
},
},
},
Expand Down Expand Up @@ -217,11 +232,17 @@ var (
Usage: "Additional `VIDEO_GRANT` fields. It'll be merged with other arguments (JSON formatted)",
},
},
MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags,
},
}
)

func createToken(ctx context.Context, c *cli.Command) error {
tokenOnly := c.Bool("token-only")
jsonOutput := c.Bool("json")
stdout := c.Root().Writer
stderr := c.Root().ErrWriter

name := c.String("name")
metadata := c.String("metadata")
validFor := c.String("valid-for")
Expand Down Expand Up @@ -254,13 +275,17 @@ func createToken(ctx context.Context, c *cli.Command) error {
participant := c.String("identity")
if participant == "" {
participant = util.ExpandTemplate("participant-%x")
fmt.Printf("Using generated participant identity [%s]\n", util.Accented(participant))
if !tokenOnly && !jsonOutput {
fmt.Fprintf(stderr, "Using generated participant identity [%s]\n", util.Accented(participant))
}
}

room := c.String("room")
if room == "" {
room = util.ExpandTemplate("room-%t")
fmt.Printf("Using generated room name [%s]\n", util.Accented(room))
if !tokenOnly && !jsonOutput {
fmt.Fprintf(stderr, "Using generated room name [%s]\n", util.Accented(room))
}
}

grant := &auth.VideoGrant{
Expand Down Expand Up @@ -419,7 +444,9 @@ func createToken(ctx context.Context, c *cli.Command) error {
at.SetName(name)
if validFor != "" {
if dur, err := time.ParseDuration(validFor); err == nil {
fmt.Println("valid for (mins): ", int(dur/time.Minute))
if !tokenOnly && !jsonOutput {
fmt.Fprintf(stderr, "valid for (mins): %d\n", int(dur/time.Minute))
}
at.SetValidFor(dur)
} else {
return err
Expand All @@ -431,13 +458,16 @@ func createToken(ctx context.Context, c *cli.Command) error {
return err
}

fmt.Println("Token grants:")
util.PrintJSON(at.GetGrants())
fmt.Println()
if project.URL != "" {
fmt.Println("Project URL:", project.URL)
if err = printTokenCreateOutput(stdout, tokenOnly, jsonOutput, tokenCreateOutput{
AccessToken: token,
ProjectURL: project.URL,
Identity: participant,
Name: name,
Room: room,
Grants: at.GetGrants(),
}); err != nil {
return err
}
fmt.Println("Access token:", token)

if c.IsSet("open") {
switch c.String("open") {
Expand All @@ -459,3 +489,33 @@ func accessToken(apiKey, apiSecret string, grant *auth.VideoGrant, identity stri
SetIdentity(identity)
return at
}

type tokenCreateOutput struct {
AccessToken string `json:"access_token"`
ProjectURL string `json:"project_url,omitempty"`
Identity string `json:"identity"`
Name string `json:"name"`
Room string `json:"room"`
Grants *auth.ClaimGrants `json:"grants"`
}

func printTokenCreateOutput(w io.Writer, tokenOnly, jsonOutput bool, out tokenCreateOutput) error {
switch {
case tokenOnly:
_, _ = fmt.Fprintln(w, out.AccessToken)
case jsonOutput:
return util.PrintJSONTo(w, out)
default:
_, _ = fmt.Fprintln(w, "Token grants:")
if err := util.PrintJSONTo(w, out.Grants); err != nil {
return err
}
_, _ = fmt.Fprintln(w)
if out.ProjectURL != "" {
_, _ = fmt.Fprintln(w, "Project URL:", out.ProjectURL)
}
_, _ = fmt.Fprintln(w, "Access token:", out.AccessToken)
}

return nil
}
107 changes: 107 additions & 0 deletions cmd/lk/token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package main

import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"

"github.com/livekit/protocol/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v3"
)

func TestTokenCommandTree(t *testing.T) {
tokenCmd := findCommandByName(TokenCommands, "token")
require.NotNil(t, tokenCmd, "top-level 'token' command must exist")

createCmd := findCommandByName(tokenCmd.Commands, "create")
require.NotNil(t, createCmd, "'token create' command must exist")
require.NotNil(t, createCmd.Action, "'token create' must have an action")
assert.True(t, commandHasFlag(createCmd, "json"), "'token create' must have --json")
assert.True(t, commandHasFlag(createCmd, "token-only"), "'token create' must have --token-only")

deprecatedCreateCmd := findCommandByName(TokenCommands, "create-token")
require.NotNil(t, deprecatedCreateCmd, "deprecated 'create-token' command must exist")
assert.True(t, commandHasFlag(deprecatedCreateCmd, "json"), "'create-token' must have --json")
assert.True(t, commandHasFlag(deprecatedCreateCmd, "token-only"), "'create-token' must have --token-only")
}

func TestTokenOutputFlagsAreMutuallyExclusive(t *testing.T) {
var actionCalled bool
app := &cli.Command{
Name: "lk",
MutuallyExclusiveFlags: tokenOutputMutuallyExclusiveFlags,
Action: func(ctx context.Context, cmd *cli.Command) error {
actionCalled = true
return nil
},
}

err := app.Run(context.Background(), []string{"lk", "--json", "--token-only"})
require.Error(t, err)
assert.False(t, actionCalled)
assert.Contains(t, err.Error(), "option json cannot be set along with option token-only")
}

func TestPrintTokenCreateOutput(t *testing.T) {
out := tokenCreateOutput{
AccessToken: "token-value",
ProjectURL: "https://example.livekit.cloud",
Identity: "test-id",
Name: "test-name",
Room: "test-room",
Grants: &auth.ClaimGrants{Identity: "test-id"},
}

var stdout bytes.Buffer
err := printTokenCreateOutput(&stdout, true, false, out)
require.NoError(t, err)
assert.Equal(t, "token-value\n", stdout.String())

stdout.Reset()
err = printTokenCreateOutput(&stdout, false, true, out)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(stdout.Bytes(), &decoded))
assert.Equal(t, "token-value", decoded["access_token"])
assert.Equal(t, "https://example.livekit.cloud", decoded["project_url"])
assert.Equal(t, "test-id", decoded["identity"])

stdout.Reset()
err = printTokenCreateOutput(&stdout, false, false, out)
require.NoError(t, err)
assert.Contains(t, stdout.String(), "Token grants:")
assert.Contains(t, stdout.String(), "Project URL: https://example.livekit.cloud")
assert.Contains(t, stdout.String(), "Access token: token-value")
}

func commandHasFlag(cmd *cli.Command, flagName string) bool {
for _, flag := range commandFlags(cmd) {
if slicesContains(flag.Names(), flagName) {
return true
}
}
return false
}

func commandFlags(cmd *cli.Command) []cli.Flag {
flags := append([]cli.Flag{}, cmd.Flags...)
for _, group := range cmd.MutuallyExclusiveFlags {
for _, path := range group.Flags {
flags = append(flags, path...)
}
}
return flags
}

func slicesContains(items []string, item string) bool {
for _, current := range items {
if strings.EqualFold(current, item) {
return true
}
}
return false
}
17 changes: 14 additions & 3 deletions pkg/util/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,29 @@ package util
import (
"encoding/json"
"fmt"
"io"
"os"

"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)

func PrintJSON(obj any) {
_ = PrintJSONTo(os.Stdout, obj)
}

func PrintJSONTo(w io.Writer, obj any) error {
const indent = " "
var txt []byte
var err error
if m, ok := obj.(proto.Message); ok {
txt, _ = protojson.MarshalOptions{Indent: indent}.Marshal(m)
txt, err = protojson.MarshalOptions{Indent: indent}.Marshal(m)
} else {
txt, _ = json.MarshalIndent(obj, "", indent)
txt, err = json.MarshalIndent(obj, "", indent)
}
if err != nil {
return err
}
fmt.Println(string(txt))
_, err = fmt.Fprintln(w, string(txt))
return err
}
Loading