Skip to content
Merged
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
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,52 @@ slck canvas delete F12345ABC

> **Note:** Canvas commands require additional Slack app scopes not included in the default manifest. See [Slack Canvas API docs](https://docs.slack.dev/surfaces/canvases/) for required scopes.

### Apps

Manage Slack apps through their manifests, so an app's configuration can live in version control and be reviewed like code.

```bash
# Store an app configuration token pair (generate one at api.slack.com/apps,
# "Your App Configuration Tokens"); the JSON is
# {"token": "xoxe.xoxp-...", "refresh_token": "xoxe-...", "exp": <unix seconds>}
<command that prints the pair> | slck set-credential --key app_config_token --stdin

# Export an app's manifest
slck apps get A0123456789 > app-manifest.json

# Check a manifest, optionally as an update to an existing app
slck apps validate --file app-manifest.json --app A0123456789

# See what an update would change (exit 0 same, 1 different, 2 could not compare)
slck apps diff A0123456789 --file app-manifest.json

# Apply it
slck apps update A0123456789 --file app-manifest.json

# Create an app; its secrets go only to a new 0600 file
slck apps create --file app-manifest.json --credentials-file app-credentials.json

# Permanently delete an app (prompts; --force skips the prompt)
slck apps delete A0123456789
```

#### Apps Command Reference

| Command | Flags | Description |
|---------|-------|-------------|
| `get <app-id>` | `--to-file` | Print an app's manifest as indented JSON with sorted keys |
| `validate` | `--file`, `--app` | Check a manifest with `apps.manifest.validate` |
| `diff <app-id>` | `--file` | Compare the live manifest with a file, one line per difference |
| `create` | `--file`, `--credentials-file` | Create an app; prints its ID and install URL |
| `update <app-id>` | `--file` | Replace an app's manifest; says when a reinstall is needed |
| `delete <app-id>` | `--force` | Permanently delete an app |

Manifest files are JSON, or YAML when the name ends in `.yaml` or `.yml`.

The apps commands authenticate with the app configuration token, not the bot or user token. slck rotates the pair with `tooling.tokens.rotate` when the access token is within five minutes of expiry or Slack rejects it, and saves the new pair to the keyring before using it. `slck config show` shows when the access token expires and which team it belongs to, never the tokens.

Installing an app to a workspace and reading its bot token need a person in a browser; these commands do neither.

### Identity

```bash
Expand Down Expand Up @@ -884,7 +930,7 @@ Ingress is at the top level, not under `config`:
| Command | Flags | Description |
|---------|-------|-------------|
| `slck init` | `--bot-token-from-env`, `--user-token-from-env`, `--bot-token-stdin`, `--overwrite`, `--no-verify` | Guided setup; stores into the keyring |
| `slck set-credential` | `--key`, `--stdin`, `--from-env`, `--ref` | Set one credential (stdin/env only) |
| `slck set-credential` | `--key`, `--stdin`, `--from-env`, `--ref` | Set one credential (stdin/env only): `bot_token`, `user_token` or `app_config_token` |

`config` subcommands (none accept a secret value):

Expand Down
5 changes: 4 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ slack-chat-api/
## Output Contract

Resource and mutation-success commands emit text or table output. JSON is
reserved for local control-plane carve-outs, currently `slck config show --json`.
reserved for local control-plane carve-outs, currently `slck config show --json`,
and for `slck apps get`, whose output is the app's manifest itself (the
document `apps validate`, `diff`, `create` and `update` read back), not a
rendering of it.
The project-level JSON-vs-text contract lives in `ARCHITECTURE.md`.

Source of truth: https://github.com/open-cli-collective/slack-chat-api/blob/main/ARCHITECTURE.md
Expand Down
47 changes: 47 additions & 0 deletions integration-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,52 @@ Using **CANVAS_ID₁** from step 10.1:

---

## Part 11: Apps Tests

Needs an app configuration token pair for the test workspace, generated at api.slack.com/apps under "Your App Configuration Tokens", and the ID of an app in that workspace (APP_ID). The read-only steps change nothing; steps 11.4 and 11.5 create and delete a throwaway app.

### 11.1 Store the Token Pair

| Step | Command | Expected |
|------|---------|----------|
| 1 | `printf '{"token":"%s","refresh_token":"%s","exp":%s}' "$ACCESS" "$REFRESH" "$EXP" \| slck set-credential --key app_config_token --stdin` | "Stored app_config_token in ..." with no token in the output |
| 2 | `slck config show` | `app_config_token: present, access token expires ...` |

### 11.2 Read-Only

| Step | Command | Expected |
|------|---------|----------|
| 1 | `slck apps get <APP_ID> --to-file app.json` | "Wrote the manifest of ..." |
| 2 | `slck apps validate --file app.json --app <APP_ID>` | "Valid: app.json", exit 0 |
| 3 | `slck apps diff <APP_ID> --file app.json` | "No differences", exit 0 |
| 4 | Add a scope to `app.json`, then `slck apps diff <APP_ID> --file app.json` | One `~ /oauth_config/scopes/bot` line, exit 1 |
| 5 | `slck apps diff <APP_ID> --file missing.json` | Read error, exit 2 |

### 11.3 Rotation

| Step | Command | Expected |
|------|---------|----------|
| 1 | Store a pair whose `exp` is in the past (11.1 with `EXP` set to an earlier time), then `slck apps get <APP_ID>` | Manifest printed |
| 2 | `slck config show` | A new expiry about 12 hours out and the team ID |

### 11.4 Create and Update a Throwaway App

| Step | Command | Expected |
|------|---------|----------|
| 1 | `slck apps create --file throwaway.json --credentials-file creds.json` | "Created app A..." and an install URL; no secret in the output; `creds.json` is mode 0600 |
| 2 | `slck apps update <NEW_APP_ID> --file throwaway.json` | "Updated app ..." |
| 3 | Add a scope, then `slck apps update <NEW_APP_ID> --file throwaway.json` | Also "reinstall the app ..." |

### 11.5 Cleanup: Delete the Throwaway App

| Step | Command | Expected |
|------|---------|----------|
| 1 | `slck apps delete <NEW_APP_ID> < /dev/null` | Refuses: stdin is not a terminal, pass --force |
| 2 | `slck apps delete <NEW_APP_ID> --force` | "Deleted app ..." |
| 3 | `rm creds.json` | |

---

## Troubleshooting

| Error | Cause | Solution |
Expand Down Expand Up @@ -703,3 +749,4 @@ slck messages history $TEST_CHANNEL_ID --limit 5
| Part 3B (Search) | Search test message TS₃ | `messages delete` |
| Part 5 (Destructive) | Test channels | `channels archive` |
| Part 10 (Canvas) | Test canvases CANVAS_ID₁–₃ | `canvas delete` |
| Part 11 (Apps) | Throwaway app NEW_APP_ID, `creds.json` | `apps delete --force`, `rm` |
74 changes: 74 additions & 0 deletions internal/apptoken/apptoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Package apptoken models a Slack app configuration token pair: the
// short-lived access token the apps.manifest.* methods accept, the refresh
// token that tooling.tokens.rotate exchanges for a new pair, and the access
// token's expiry.
//
// The pair is stored in the keyring as one JSON value under
// keychain.KeyAppConfigToken, so a rotation replaces both tokens in a single
// write. The stored shape is a superset of the ingress contract.
package apptoken

import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)

// RotateWithin is how close to expiry an access token is rotated before use.
// Slack issues access tokens for 12 hours.
const RotateWithin = 5 * time.Minute

// Pair is a stored app configuration token pair. Token and RefreshToken are
// secrets and are never printed.
type Pair struct {
Token string `json:"token"`
RefreshToken string `json:"refresh_token"`
// Exp is the access token's expiry in Unix seconds.
Exp int64 `json:"exp"`
// TeamID and UserID are recorded from the first rotation; the ingress
// contract does not carry them.
TeamID string `json:"team_id,omitempty"`
UserID string `json:"user_id,omitempty"`
}

// Parse validates and decodes an ingress value or a stored value. The
// ingress contract is exactly {"token", "refresh_token", "exp"}, the field
// names tooling.tokens.rotate answers with. Errors never include a token.
func Parse(raw string) (Pair, error) {
dec := json.NewDecoder(strings.NewReader(raw))
dec.DisallowUnknownFields()
var p Pair
if err := dec.Decode(&p); err != nil {
return Pair{}, errors.New(`app configuration token must be a JSON object {"token": "...", "refresh_token": "...", "exp": <unix seconds>}`)
}
if !strings.HasPrefix(p.Token, "xoxe.") {
return Pair{}, errors.New(`app configuration token: "token" is not an app configuration access token (xoxe.*)`)
}
if !strings.HasPrefix(p.RefreshToken, "xoxe-") {
return Pair{}, errors.New(`app configuration token: "refresh_token" is not an app configuration refresh token (xoxe-*)`)
}
if p.Exp <= 0 {
return Pair{}, errors.New(`app configuration token: "exp" must be the access token's expiry in Unix seconds`)
}
return p, nil
}

// Encode returns the stored JSON value.
func (p Pair) Encode() (string, error) {
b, err := json.Marshal(p)
if err != nil {
return "", fmt.Errorf("encode app configuration token: %w", err)
}
return string(b), nil
}

// Expiry returns the access token's expiry.
func (p Pair) Expiry() time.Time { return time.Unix(p.Exp, 0) }

// NeedsRotation reports whether the access token expires within RotateWithin
// of now.
func (p Pair) NeedsRotation(now time.Time) bool {
return !now.Add(RotateWithin).Before(p.Expiry())
}
54 changes: 54 additions & 0 deletions internal/apptoken/apptoken_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package apptoken

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
access = "xoxe.xoxp-1-access-canary-3f9a"
refresh = "xoxe-1-refresh-canary-81c2"
)

func TestParseAcceptsTheIngressContract(t *testing.T) {
p, err := Parse(`{"token": "` + access + `", "refresh_token": "` + refresh + `", "exp": 1790043200}`)
require.NoError(t, err)
assert.Equal(t, access, p.Token)
assert.Equal(t, refresh, p.RefreshToken)
assert.Equal(t, time.Unix(1790043200, 0), p.Expiry())
}

func TestParseRoundTripsAStoredPair(t *testing.T) {
stored, err := Pair{Token: access, RefreshToken: refresh, Exp: 1790043200, TeamID: "T0TEST", UserID: "U0TEST"}.Encode()
require.NoError(t, err)
p, err := Parse(stored)
require.NoError(t, err)
assert.Equal(t, "T0TEST", p.TeamID)
}

func TestParseRejectionsNeverIncludeTheValue(t *testing.T) {
for name, raw := range map[string]string{
"not json": access,
"unknown field": `{"token": "` + access + `", "refresh_token": "` + refresh + `", "exp": 1, "access": "x"}`,
"bot token": `{"token": "xoxb-1-canary", "refresh_token": "` + refresh + `", "exp": 1}`,
"swapped tokens": `{"token": "` + refresh + `", "refresh_token": "` + access + `", "exp": 1}`,
"missing expiry": `{"token": "` + access + `", "refresh_token": "` + refresh + `"}`,
} {
t.Run(name, func(t *testing.T) {
_, err := Parse(raw)
require.Error(t, err)
assert.NotContains(t, err.Error(), "canary")
})
}
}

func TestNeedsRotation(t *testing.T) {
exp := time.Unix(1790043200, 0)
p := Pair{Exp: exp.Unix()}
assert.False(t, p.NeedsRotation(exp.Add(-time.Hour)))
assert.True(t, p.NeedsRotation(exp.Add(-RotateWithin)))
assert.True(t, p.NeedsRotation(exp.Add(time.Hour)))
}
Loading
Loading