Skip to content

Commit d9def16

Browse files
committed
feat(auth): add Coachless authentication controls
Add a reusable Coachless session service for status, login, logout, and auth bundle import/export. Wire the service into the CLI, UI endpoints, and browser controls so users can manage stored credentials.
1 parent 67b8ed6 commit d9def16

24 files changed

Lines changed: 1696 additions & 23 deletions

cmd/lol-autobuild/app_adapters.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,62 @@ func (c appUpdateChecker) Check(ctx context.Context) (app.UpdateCheckResult, err
132132
return out, err
133133
}
134134

135+
type appCoachlessAuthSession struct {
136+
session *auth.CoachlessSession
137+
}
138+
139+
func (s appCoachlessAuthSession) Status(ctx context.Context) app.CoachlessAuthState {
140+
if s.session == nil {
141+
return app.CoachlessAuthState{
142+
Status: app.CoachlessAuthStatusMissing,
143+
Plan: app.CoachlessAuthPlanUnknown,
144+
}
145+
}
146+
147+
return appCoachlessAuthStateFromAuth(s.session.Status(ctx))
148+
}
149+
150+
func (s appCoachlessAuthSession) Login(ctx context.Context) error {
151+
return s.session.Login(ctx)
152+
}
153+
154+
func (s appCoachlessAuthSession) Logout(ctx context.Context) error {
155+
return s.session.Logout(ctx)
156+
}
157+
158+
func appCoachlessAuthStateFromAuth(status auth.CoachlessSessionState) app.CoachlessAuthState {
159+
return app.CoachlessAuthState{
160+
Status: appCoachlessAuthStatusFromAuth(status.Status),
161+
Plan: appCoachlessAuthPlanFromAuth(status.Plan),
162+
ExpiresAt: status.ExpiresAt,
163+
Message: status.Message,
164+
}
165+
}
166+
167+
func appCoachlessAuthStatusFromAuth(status auth.CoachlessSessionStatus) app.CoachlessAuthStatus {
168+
switch status {
169+
case auth.CoachlessSessionStatusStored:
170+
return app.CoachlessAuthStatusStored
171+
case auth.CoachlessSessionStatusExpired:
172+
return app.CoachlessAuthStatusExpired
173+
case auth.CoachlessSessionStatusError:
174+
return app.CoachlessAuthStatusError
175+
default:
176+
return app.CoachlessAuthStatusMissing
177+
}
178+
}
179+
180+
func appCoachlessAuthPlanFromAuth(plan auth.CoachlessPlan) app.CoachlessAuthPlan {
181+
switch plan {
182+
case auth.CoachlessPlanFree:
183+
return app.CoachlessAuthPlanFree
184+
case auth.CoachlessPlanPremium:
185+
return app.CoachlessAuthPlanPremium
186+
default:
187+
return app.CoachlessAuthPlanUnknown
188+
}
189+
}
190+
135191
func appMessageFromErr(err error) app.UserMessage {
136192
switch {
137193
case err == nil:

cmd/lol-autobuild/app_adapters_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,28 @@ func TestAppLCUStatusFromLCU(t *testing.T) {
143143
}
144144
}
145145

146+
func TestAppCoachlessAuthStateFromAuth(t *testing.T) {
147+
t.Parallel()
148+
149+
expiresAt := time.Date(2026, time.May, 4, 12, 0, 0, 0, time.UTC)
150+
got := appCoachlessAuthStateFromAuth(auth.CoachlessSessionState{
151+
Status: auth.CoachlessSessionStatusStored,
152+
Plan: auth.CoachlessPlanPremium,
153+
ExpiresAt: &expiresAt,
154+
Message: "status",
155+
})
156+
157+
if got.Status != app.CoachlessAuthStatusStored || got.Plan != app.CoachlessAuthPlanPremium {
158+
t.Fatalf("converted status = %+v", got)
159+
}
160+
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expiresAt) {
161+
t.Fatalf("converted ExpiresAt = %v, want %v", got.ExpiresAt, expiresAt)
162+
}
163+
if got.Message != "status" {
164+
t.Fatalf("converted Message = %q, want status", got.Message)
165+
}
166+
}
167+
146168
type stubUpdateSource struct {
147169
currentVersion string
148170
result update.Result

cmd/lol-autobuild/auth_cmd.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"os"
9+
"strings"
10+
"time"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/controlado/lol-autobuild/internal/auth"
15+
"github.com/controlado/lol-autobuild/internal/config"
16+
)
17+
18+
type authCommandSession interface {
19+
Status(context.Context) auth.CoachlessSessionState
20+
Login(context.Context) error
21+
Logout(context.Context) error
22+
Export(context.Context) (string, error)
23+
Import(context.Context, string) error
24+
}
25+
26+
type authCommandSessionFactory func(config.Config) authCommandSession
27+
28+
type authStatusOutput struct {
29+
Status auth.CoachlessSessionStatus `json:"status"`
30+
Plan auth.CoachlessPlan `json:"plan"`
31+
ExpiresAt *time.Time `json:"expires_at,omitempty"`
32+
Message string `json:"message,omitempty"`
33+
}
34+
35+
func authCmd() *cobra.Command {
36+
return newAuthCmd(func(cfg config.Config) authCommandSession { return buildCoachlessAuthSession(cfg) })
37+
}
38+
39+
func newAuthCmd(sessionFactory authCommandSessionFactory) *cobra.Command {
40+
var configPath string
41+
42+
cmd := &cobra.Command{
43+
Use: "auth",
44+
Short: "Manage Coachless authentication",
45+
Args: cobra.NoArgs,
46+
}
47+
cmd.PersistentFlags().StringVar(&configPath, "config", defaultConfigPath, "Path to YAML configuration file")
48+
49+
cmd.AddCommand(authStatusCmd(&configPath, sessionFactory))
50+
cmd.AddCommand(authLoginCmd(&configPath, sessionFactory))
51+
cmd.AddCommand(authLogoutCmd(&configPath, sessionFactory))
52+
cmd.AddCommand(authExportCmd(&configPath, sessionFactory))
53+
cmd.AddCommand(authImportCmd(&configPath, sessionFactory))
54+
55+
return cmd
56+
}
57+
58+
func authStatusCmd(configPath *string, sessionFactory authCommandSessionFactory) *cobra.Command {
59+
return &cobra.Command{
60+
Use: "status",
61+
Short: "Show Coachless authentication status",
62+
Args: cobra.NoArgs,
63+
RunE: func(cmd *cobra.Command, _ []string) error {
64+
session, err := loadAuthCommandSession(*configPath, sessionFactory)
65+
if err != nil {
66+
return err
67+
}
68+
69+
status := session.Status(cmd.Context())
70+
out := authStatusOutput{
71+
Status: status.Status,
72+
Plan: status.Plan,
73+
ExpiresAt: status.ExpiresAt,
74+
Message: status.Message,
75+
}
76+
77+
encoded, err := json.MarshalIndent(out, "", " ")
78+
if err != nil {
79+
return fmt.Errorf("encode auth status: %w", err)
80+
}
81+
82+
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(encoded))
83+
return err
84+
},
85+
}
86+
}
87+
88+
func authLoginCmd(configPath *string, sessionFactory authCommandSessionFactory) *cobra.Command {
89+
return &cobra.Command{
90+
Use: "login",
91+
Short: "Sign in to Coachless",
92+
Args: cobra.NoArgs,
93+
RunE: func(cmd *cobra.Command, _ []string) error {
94+
session, err := loadAuthCommandSession(*configPath, sessionFactory)
95+
if err != nil {
96+
return err
97+
}
98+
if err := session.Login(cmd.Context()); err != nil {
99+
return err
100+
}
101+
102+
_, err = fmt.Fprintln(cmd.OutOrStdout(), "Coachless authentication saved.")
103+
return err
104+
},
105+
}
106+
}
107+
108+
func authLogoutCmd(configPath *string, sessionFactory authCommandSessionFactory) *cobra.Command {
109+
return &cobra.Command{
110+
Use: "logout",
111+
Short: "Sign out of Coachless",
112+
Args: cobra.NoArgs,
113+
RunE: func(cmd *cobra.Command, _ []string) error {
114+
session, err := loadAuthCommandSession(*configPath, sessionFactory)
115+
if err != nil {
116+
return err
117+
}
118+
if err := session.Logout(cmd.Context()); err != nil {
119+
return err
120+
}
121+
122+
_, err = fmt.Fprintln(cmd.OutOrStdout(), "Coachless authentication cleared.")
123+
return err
124+
},
125+
}
126+
}
127+
128+
func authExportCmd(configPath *string, sessionFactory authCommandSessionFactory) *cobra.Command {
129+
return &cobra.Command{
130+
Use: "export",
131+
Short: "Export Coachless authentication",
132+
Hidden: true,
133+
Args: cobra.NoArgs,
134+
RunE: func(cmd *cobra.Command, _ []string) error {
135+
session, err := loadAuthCommandSession(*configPath, sessionFactory)
136+
if err != nil {
137+
return err
138+
}
139+
140+
bundle, err := session.Export(cmd.Context())
141+
if err != nil {
142+
return err
143+
}
144+
145+
if _, err := fmt.Fprintln(cmd.ErrOrStderr(), "Warning: exported Coachless authentication contains secret tokens. Keep it private."); err != nil {
146+
return err
147+
}
148+
_, err = fmt.Fprintln(cmd.OutOrStdout(), bundle)
149+
return err
150+
},
151+
}
152+
}
153+
154+
func authImportCmd(configPath *string, sessionFactory authCommandSessionFactory) *cobra.Command {
155+
var filePath string
156+
157+
cmd := &cobra.Command{
158+
Use: "import",
159+
Short: "Import Coachless authentication",
160+
Hidden: true,
161+
Args: cobra.NoArgs,
162+
RunE: func(cmd *cobra.Command, _ []string) error {
163+
session, err := loadAuthCommandSession(*configPath, sessionFactory)
164+
if err != nil {
165+
return err
166+
}
167+
168+
raw, err := readAuthImportBundle(cmd, filePath)
169+
if err != nil {
170+
return err
171+
}
172+
if err := session.Import(cmd.Context(), raw); err != nil {
173+
return err
174+
}
175+
176+
_, err = fmt.Fprintln(cmd.OutOrStdout(), "Coachless authentication imported.")
177+
return err
178+
},
179+
}
180+
cmd.Flags().StringVar(&filePath, "file", "", "Path to Coachless authentication bundle")
181+
return cmd
182+
}
183+
184+
func loadAuthCommandSession(configPath string, sessionFactory authCommandSessionFactory) (authCommandSession, error) {
185+
cfg, err := loadConfigAndLogging(configPath)
186+
if err != nil {
187+
return nil, err
188+
}
189+
190+
session := sessionFactory(cfg)
191+
if session == nil {
192+
return nil, fmt.Errorf("coachless authentication is unavailable")
193+
}
194+
return session, nil
195+
}
196+
197+
func readAuthImportBundle(cmd *cobra.Command, filePath string) (string, error) {
198+
if strings.TrimSpace(filePath) != "" {
199+
raw, err := os.ReadFile(filePath)
200+
if err != nil {
201+
return "", fmt.Errorf("read auth bundle file: %w", err)
202+
}
203+
return string(raw), nil
204+
}
205+
206+
raw, err := io.ReadAll(cmd.InOrStdin())
207+
if err != nil {
208+
return "", fmt.Errorf("read auth bundle stdin: %w", err)
209+
}
210+
return string(raw), nil
211+
}

0 commit comments

Comments
 (0)