Skip to content

Commit fbb73ea

Browse files
committed
feat: Auto-configure customer context for DoiT employees on first login
New DoiT employees hit a 403 on `dci login` because the validate endpoint requires a customerContext that hasn't been set yet. After OAuth succeeds (token is always cached), inspect the JWT for the DoitEmployee claim and: - Set default customerContext to doit.com automatically - Show customer-context command in help for Doers - Print a styled hint on 401/403 when a Doer has no context configured
1 parent 1352687 commit fbb73ea

2 files changed

Lines changed: 236 additions & 4 deletions

File tree

main.go

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package main
22

33
import (
44
"embed"
5+
"encoding/base64"
56
"encoding/json"
67
"fmt"
8+
"io"
79
"io/fs"
810
"os"
911
"path/filepath"
@@ -169,9 +171,18 @@ func run() (exitCode int) {
169171
brandRootCommand()
170172
brandDCIRootCommand()
171173
registerStatusCommands(configDir)
172-
registerAuthCommands()
174+
registerAuthCommands(configDir)
173175
registerCustomerContextCommands(configDir)
174176
registerSkillCommands()
177+
// Unhide the customer-context command for DoiT employees so it appears in help.
178+
if cachedTokenIsDoer() {
179+
for _, c := range cli.Root.Commands() {
180+
if c.Use == "customer-context" {
181+
c.Hidden = false
182+
break
183+
}
184+
}
185+
}
175186
addOutputFlag()
176187
hideGlobalFlags()
177188
customizeDCIUsage()
@@ -182,9 +193,12 @@ func run() (exitCode int) {
182193

183194
if err := cli.Run(); err != nil {
184195
fmt.Fprintf(os.Stderr, "%v\n", err)
196+
maybeHintDoerContext(1, configDir)
185197
return 1
186198
}
187-
return cli.GetExitCode()
199+
code := cli.GetExitCode()
200+
maybeHintDoerContext(code, configDir)
201+
return code
188202
}
189203

190204
func rejectProfileFlags(args []string) error {
@@ -779,7 +793,86 @@ func authSource() string {
779793
return "OAuth (DoiT Console)"
780794
}
781795

782-
func registerAuthCommands() {
796+
// maybeHintDoerContext prints a targeted hint when a @doit.com user hits a 403
797+
// without a customer context set — covering both interactive and CI/CD usage.
798+
func maybeHintDoerContext(exitCode int, configDir string) {
799+
status := cli.GetLastStatus()
800+
if exitCode == 0 || (status != 401 && status != 403) {
801+
return
802+
}
803+
if !cachedTokenIsDoer() {
804+
return
805+
}
806+
if readCustomerContext(configDir) != "" {
807+
return
808+
}
809+
if term.IsTerminal(int(os.Stderr.Fd())) {
810+
fmt.Fprintln(os.Stderr, "")
811+
fmt.Fprintf(os.Stderr, "\033[1;33m!\033[0m DoiT employees need a customer context for API calls.\n")
812+
fmt.Fprintf(os.Stderr, " Interactive: \033[1mdci customer-context set doit.com\033[0m\n")
813+
fmt.Fprintf(os.Stderr, " CI/scripts: \033[1mexport DCI_CUSTOMER_CONTEXT=doit.com\033[0m\n")
814+
fmt.Fprintln(os.Stderr, "")
815+
} else {
816+
fmt.Fprintln(os.Stderr, "")
817+
fmt.Fprintln(os.Stderr, "! DoiT employees need a customer context for API calls.")
818+
fmt.Fprintln(os.Stderr, " Interactive: dci customer-context set doit.com")
819+
fmt.Fprintln(os.Stderr, " CI/scripts: export DCI_CUSTOMER_CONTEXT=doit.com")
820+
fmt.Fprintln(os.Stderr, "")
821+
}
822+
}
823+
824+
// applyDoerContext auto-configures the customer context to "doit.com" for
825+
// @doit.com accounts that haven't set one yet. The validate endpoint requires
826+
// customerContext for DoiT employees; calling this after the OAuth token is
827+
// cached fixes the chicken-and-egg problem on first login. Returns true if the
828+
// context was written so the caller can clear a 403 error from validate.
829+
func applyDoerContext(configDir string) bool {
830+
if !cachedTokenIsDoer() {
831+
return false
832+
}
833+
if readCustomerContext(configDir) != "" {
834+
return false // already configured, don't overwrite
835+
}
836+
err := os.WriteFile(customerContextPath(configDir), []byte("doit.com\n"), 0o600)
837+
if err != nil {
838+
return false
839+
}
840+
fmt.Fprintln(os.Stderr, "Detected DoiT account. Set default customer context to 'doit.com'.")
841+
fmt.Fprintln(os.Stderr, "To use a different context: dci customer-context set <CONTEXT>")
842+
return true
843+
}
844+
845+
// cachedTokenIsDoer reports whether the cached OAuth JWT contains
846+
// DoitEmployee: true. This is more reliable than email-domain matching because
847+
// it is an explicit claim set by the DoiT auth server and is domain-independent.
848+
// Returns false if the cache is empty, the token is absent, or the JWT is malformed.
849+
func cachedTokenIsDoer() bool {
850+
if cli.Cache == nil {
851+
return false
852+
}
853+
token := cli.Cache.GetString("dci:default.token")
854+
if token == "" {
855+
return false
856+
}
857+
parts := strings.Split(token, ".")
858+
if len(parts) != 3 {
859+
return false
860+
}
861+
// JWT payload is base64url-encoded without padding.
862+
b, err := base64.RawURLEncoding.DecodeString(parts[1])
863+
if err != nil {
864+
return false
865+
}
866+
var claims struct {
867+
DoitEmployee bool `json:"DoitEmployee"`
868+
}
869+
if err := json.Unmarshal(b, &claims); err != nil {
870+
return false
871+
}
872+
return claims.DoitEmployee
873+
}
874+
875+
func registerAuthCommands(configDir string) {
783876
cli.Root.AddCommand(&cobra.Command{
784877
Use: "login",
785878
Aliases: []string{"auth", "init"},
@@ -791,8 +884,26 @@ func registerAuthCommands() {
791884
return fmt.Errorf("login is not needed when DCI_API_KEY is set")
792885
}
793886
// Trigger the OAuth flow by calling a lightweight endpoint.
887+
// Suppress the validate response body — login only needs the OAuth
888+
// side effect (token cached), not the API output.
794889
os.Args = []string{os.Args[0], "dci", "validate"}
795-
if err := cli.Run(); err != nil {
890+
oldOut := cli.Stdout
891+
cli.Stdout = io.Discard
892+
err := cli.Run()
893+
cli.Stdout = oldOut
894+
895+
// Auto-configure DoiT employees who have no customer context set.
896+
// The validate endpoint requires customerContext for @doit.com accounts,
897+
// causing a 403 on first login before any context is configured. The OAuth
898+
// token exchange succeeds (token is cached) even when validate returns 403,
899+
// so we can inspect the token here and fix the chicken-and-egg problem.
900+
if applyDoerContext(configDir) {
901+
err = nil // the 403 was due to missing context; auth itself succeeded
902+
// Reset the HTTP status so GetExitCode() returns 0 for this process.
903+
viper.Set("rsh-ignore-status-code", true)
904+
}
905+
906+
if err != nil {
796907
return err
797908
}
798909
fmt.Fprintln(os.Stderr, "Authenticated successfully.")

main_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package main
22

33
import (
44
"context"
5+
"encoding/base64"
6+
"encoding/json"
57
"fmt"
68
"io/fs"
79
"os"
@@ -16,6 +18,7 @@ import (
1618
"github.com/mattn/go-runewidth"
1719
"github.com/rest-sh/restish/cli"
1820
"github.com/spf13/cobra"
21+
"github.com/spf13/viper"
1922
)
2023

2124
func TestNormalizeArgs(t *testing.T) {
@@ -1195,3 +1198,121 @@ func TestInstallSkillFileCount(t *testing.T) {
11951198
t.Errorf("expected %d files, got %d: %v", len(expectedSkillFiles), len(installedFiles), installedFiles)
11961199
}
11971200
}
1201+
1202+
const testTokenCacheKey = "dci:default.token"
1203+
1204+
// makeTestJWT builds a minimal unsigned JWT (header.payload.) with the given claims.
1205+
// claims is marshalled as-is so callers can pass any JSON-serialisable map.
1206+
func makeTestJWT(claims map[string]interface{}) string {
1207+
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`))
1208+
payload, _ := json.Marshal(claims)
1209+
return header + "." + base64.RawURLEncoding.EncodeToString(payload) + "."
1210+
}
1211+
1212+
// doerJWT returns a test JWT with DoitEmployee: true.
1213+
func doerJWT() string {
1214+
return makeTestJWT(map[string]interface{}{"DoitEmployee": true, "sub": "jane@doit-intl.com"})
1215+
}
1216+
1217+
// nonDoerJWT returns a test JWT with DoitEmployee: false.
1218+
func nonDoerJWT() string {
1219+
return makeTestJWT(map[string]interface{}{"DoitEmployee": false, "sub": "user@example.com"})
1220+
}
1221+
1222+
// writeContextFile writes ctx to the customer context file in dir, fataling on error.
1223+
func writeContextFile(t *testing.T, dir, ctx string) {
1224+
t.Helper()
1225+
if err := os.WriteFile(customerContextPath(dir), []byte(ctx+"\n"), 0o600); err != nil {
1226+
t.Fatal(err)
1227+
}
1228+
}
1229+
1230+
// setupTestCache replaces cli.Cache with a fresh in-memory viper instance and
1231+
// restores the original on test cleanup.
1232+
func setupTestCache(t *testing.T) {
1233+
t.Helper()
1234+
old := cli.Cache
1235+
cli.Cache = viper.New()
1236+
t.Cleanup(func() { cli.Cache = old })
1237+
}
1238+
1239+
func TestCachedTokenIsDoer(t *testing.T) {
1240+
setupTestCache(t)
1241+
1242+
tests := []struct {
1243+
name string
1244+
token string
1245+
want bool
1246+
}{
1247+
{name: "DoitEmployee true", token: doerJWT(), want: true},
1248+
{name: "DoitEmployee false", token: nonDoerJWT(), want: false},
1249+
{name: "JWT without DoitEmployee claim", token: makeTestJWT(map[string]interface{}{"sub": "user@example.com"}), want: false},
1250+
{name: "empty token", token: "", want: false},
1251+
{name: "not a JWT", token: "not-a-jwt", want: false},
1252+
{name: "invalid base64 in payload", token: "header.!!invalid!!.sig", want: false},
1253+
}
1254+
1255+
for _, tt := range tests {
1256+
t.Run(tt.name, func(t *testing.T) {
1257+
cli.Cache.Set(testTokenCacheKey, tt.token)
1258+
if got := cachedTokenIsDoer(); got != tt.want {
1259+
t.Errorf("cachedTokenIsDoer() = %v, want %v", got, tt.want)
1260+
}
1261+
})
1262+
}
1263+
}
1264+
1265+
func TestApplyDoerContext(t *testing.T) {
1266+
setupTestCache(t)
1267+
1268+
tests := []struct {
1269+
name string
1270+
token string
1271+
existingContext string
1272+
wantResult bool
1273+
wantContext string
1274+
}{
1275+
{
1276+
name: "sets doit.com for Doer with no context",
1277+
token: doerJWT(),
1278+
wantResult: true,
1279+
wantContext: "doit.com",
1280+
},
1281+
{
1282+
name: "no-op for non-Doer account",
1283+
token: nonDoerJWT(),
1284+
wantResult: false,
1285+
wantContext: "",
1286+
},
1287+
{
1288+
name: "no-op when context already set",
1289+
token: doerJWT(),
1290+
existingContext: "other-customer",
1291+
wantResult: false,
1292+
wantContext: "other-customer",
1293+
},
1294+
{
1295+
name: "no-op when no cached token",
1296+
token: "",
1297+
wantResult: false,
1298+
wantContext: "",
1299+
},
1300+
}
1301+
1302+
for _, tt := range tests {
1303+
t.Run(tt.name, func(t *testing.T) {
1304+
dir := t.TempDir()
1305+
if tt.existingContext != "" {
1306+
writeContextFile(t, dir, tt.existingContext)
1307+
}
1308+
cli.Cache.Set(testTokenCacheKey, tt.token)
1309+
1310+
if got := applyDoerContext(dir); got != tt.wantResult {
1311+
t.Errorf("applyDoerContext() = %v, want %v", got, tt.wantResult)
1312+
}
1313+
if ctx := readCustomerContext(dir); ctx != tt.wantContext {
1314+
t.Errorf("customerContext = %q, want %q", ctx, tt.wantContext)
1315+
}
1316+
})
1317+
}
1318+
}

0 commit comments

Comments
 (0)