diff --git a/.gitignore b/.gitignore index e660fd9..5f2766f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ bin/ +logs/app.log diff --git a/cli/cmd/auth_login.go b/cli/cmd/auth_login.go index 9209865..88a19b2 100644 --- a/cli/cmd/auth_login.go +++ b/cli/cmd/auth_login.go @@ -2,38 +2,62 @@ package cmd import ( "bufio" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" + "io" + "net/http" + "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" + "time" ) -// TODO: Update with real auth URL -const authUrl = "https://example.com/login" +const ( + serverBaseUrl = "http://localhost:3000" + authUrl = serverBaseUrl + "/oauth/authorize" + tokenUrl = serverBaseUrl + "/oauth/token" + clientID = "cli" + redirectURI = serverBaseUrl + "/oauth/callback" +) type LoginCmd struct { } -func openUrl() error { +func generateRandomString(n int) (string, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} +func generateCodeChallenge(verifier string) string { + s := sha256.New() + s.Write([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(s.Sum(nil)) +} + +func openUrl(url string) error { var cmd *exec.Cmd - url := authUrl switch runtime.GOOS { case "darwin": cmd = exec.Command("open", url) case "windows": cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - default: // Linux, BSD, etc. + default: // Linux cmd = exec.Command("xdg-open", url) } - return cmd.Start() } -func saveTokenToConfig(ctx *AppContext, token string) error { +func saveTokenToConfig(ctx *AppContext, token string, refreshToken string, expiresAt time.Time) error { configPath := defaultConfigPath() if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { return fmt.Errorf("failed to create config directory: %w", err) @@ -43,6 +67,8 @@ func saveTokenToConfig(ctx *AppContext, token string) error { ctx.Config = &Config{} } ctx.Config.AccessToken = token + ctx.Config.RefreshToken = refreshToken + ctx.Config.ExpiresAt = expiresAt data, err := json.MarshalIndent(ctx.Config, "", " ") if err != nil { @@ -51,59 +77,115 @@ func saveTokenToConfig(ctx *AppContext, token string) error { if err := os.WriteFile(configPath, data, 0o600); err != nil { return fmt.Errorf("failed to write config file: %w", err) } - fmt.Println("Token saved:", token) + fmt.Println("Token saved") return nil } -func getLongLivedToken(shortLivedToken string) (string, error) { - // Placeholder for actual implementation to exchange short-lived token for long-lived token - // In a real scenario, this would involve making an HTTP request to the auth server - return shortLivedToken + "_long_lived", nil +func (ctx *AppContext) RefreshAccessToken() error { + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("client_id", clientID) + data.Set("refresh_token", ctx.Config.RefreshToken) + + resp, err := http.PostForm(tokenUrl, data) + if err != nil { + return fmt.Errorf("failed to refresh token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("token refresh failed: %s", body) + } + + var tokenResp map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return fmt.Errorf("failed to decode token response: %w", err) + } + + accessToken, _ := tokenResp["access_token"].(string) + refreshToken, _ := tokenResp["refresh_token"].(string) + expiresIn, _ := tokenResp["expires_in"].(float64) + expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) + + return saveTokenToConfig(ctx, accessToken, refreshToken, expiresAt) } -func (l *LoginCmd) Run(ctx *AppContext) error { - // mist auth login - if ctx.Config != nil && ctx.Config.AccessToken != "" { +func (ctx *AppContext) CheckValidToken() error { + if ctx.Config == nil || ctx.Config.AccessToken == "" { + return fmt.Errorf("not logged in") + } - // Already logged in, ask if they want to re-login - fmt.Println("Already logged in with token:", ctx.Config.AccessToken) - fmt.Print("Re-enter token? (y/N): ") - reader := bufio.NewReader(os.Stdin) - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - if answer != "y" && answer != "yes" { - fmt.Println("Aborting login.") - return nil - } + if time.Now().Add(30 * time.Second).After(ctx.Config.ExpiresAt) { + return ctx.RefreshAccessToken() } - fmt.Println("Opening browser for authentication...") - fmt.Printf("If your browser didn't open, click here: \033]8;;%s\033\\%s\033]8;;\033\\\n", authUrl, authUrl) + return nil +} - err := openUrl() +func (l *LoginCmd) Run(ctx *AppContext) error { + verifier, err := generateRandomString(32) if err != nil { - fmt.Println("Error opening browser:", err) - return err + return fmt.Errorf("failed to generate verifier: %w", err) } - fmt.Print("token: ") + challenge := generateCodeChallenge(verifier) + + u, _ := url.Parse(authUrl) + q := u.Query() + q.Set("client_id", clientID) + q.Set("response_type", "code") + q.Set("redirect_uri", redirectURI) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + u.RawQuery = q.Encode() + + fmt.Printf("If your browser doesn't open, visit: %s\n", u.String()) + if err := openUrl(u.String()); err != nil { + fmt.Printf("Failed to open browser: %v\n", err) + } + + fmt.Print("Enter the authorization code: ") reader := bufio.NewReader(os.Stdin) - token, _ := reader.ReadString('\n') - token = strings.TrimSpace(token) + code, _ := reader.ReadString('\n') + code = strings.TrimSpace(code) - token, err = getLongLivedToken(token) - if err != nil { - fmt.Println("Error obtaining long-lived token:", err) - return err + if code == "" { + return fmt.Errorf("authorization code is required") } - err = saveTokenToConfig(ctx, token) + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", clientID) + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + data.Set("code_verifier", verifier) + + resp, err := http.PostForm(tokenUrl, data) if err != nil { - fmt.Println("Error during token saving") - return err + return fmt.Errorf("failed to exchange token: %w", err) } + defer resp.Body.Close() - fmt.Println("Saved token to config") + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("token exchange failed: %s", body) + } - return nil + var tokenResp map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return fmt.Errorf("failed to decode token response: %w", err) + } + + accessToken, ok := tokenResp["access_token"].(string) + if !ok { + return fmt.Errorf("invalid token response: missing access_token") + } + + refreshToken, _ := tokenResp["refresh_token"].(string) + expiresIn, _ := tokenResp["expires_in"].(float64) + + expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) + + return saveTokenToConfig(ctx, accessToken, refreshToken, expiresAt) } diff --git a/cli/cmd/job_list.go b/cli/cmd/job_list.go index 9e4787d..0a25a67 100644 --- a/cli/cmd/job_list.go +++ b/cli/cmd/job_list.go @@ -21,6 +21,10 @@ type Job struct { func (l *ListCmd) Run(ctx *AppContext) error { // Mock data - pull from API in real implementation + if err := ctx.CheckValidToken(); err != nil { + return err + } + jobs := []Job{ { ID: "ID:1", diff --git a/cli/cmd/job_status.go b/cli/cmd/job_status.go index 20634c8..9238113 100644 --- a/cli/cmd/job_status.go +++ b/cli/cmd/job_status.go @@ -13,6 +13,10 @@ type JobStatusCmd struct { func (j *JobStatusCmd) Run(ctx *AppContext) error { // Mock data - pull from API in real implementation + if err := ctx.CheckValidToken(); err != nil { + return err + } + jobs := []Job{{ ID: "ID:1", Name: "docker_container_name_1", diff --git a/cli/cmd/job_submit.go b/cli/cmd/job_submit.go index 22b793f..df0f67a 100644 --- a/cli/cmd/job_submit.go +++ b/cli/cmd/job_submit.go @@ -15,7 +15,9 @@ type JobSubmitCmd struct { func (j *JobSubmitCmd) Run(ctx *AppContext) error { // mist job submit