|
1 | 1 | package auth |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bufio" |
4 | 5 | "context" |
5 | 6 | "crypto/rand" |
6 | 7 | "encoding/hex" |
7 | 8 | "fmt" |
8 | 9 | "net" |
9 | 10 | "net/http" |
| 11 | + "net/url" |
10 | 12 | "os" |
11 | 13 | "os/exec" |
12 | 14 | "runtime" |
| 15 | + "strings" |
13 | 16 |
|
14 | 17 | "github.com/voska/qbo-cli/internal/errfmt" |
15 | 18 | "golang.org/x/oauth2" |
@@ -52,13 +55,36 @@ func RefreshAccessToken(ctx context.Context, clientID, clientSecret string, toke |
52 | 55 |
|
53 | 56 | const DefaultCallbackPort = 8844 |
54 | 57 |
|
55 | | -func LoginInteractive(ctx context.Context, clientID, clientSecret string) (*AuthResult, error) { |
| 58 | +func DefaultRedirectURI() string { |
| 59 | + return fmt.Sprintf("http://localhost:%d/callback", DefaultCallbackPort) |
| 60 | +} |
| 61 | + |
| 62 | +func isLocalRedirect(redirectURL string) bool { |
| 63 | + u, err := url.Parse(redirectURL) |
| 64 | + if err != nil { |
| 65 | + return false |
| 66 | + } |
| 67 | + host := u.Hostname() |
| 68 | + return host == "localhost" || host == "127.0.0.1" || host == "::1" |
| 69 | +} |
| 70 | + |
| 71 | +func LoginInteractive(ctx context.Context, clientID, clientSecret, redirectURL string) (*AuthResult, error) { |
| 72 | + if redirectURL == "" { |
| 73 | + redirectURL = DefaultRedirectURI() |
| 74 | + } |
| 75 | + |
| 76 | + if isLocalRedirect(redirectURL) { |
| 77 | + return loginLocal(ctx, clientID, clientSecret, redirectURL) |
| 78 | + } |
| 79 | + return loginManual(ctx, clientID, clientSecret, redirectURL) |
| 80 | +} |
| 81 | + |
| 82 | +func loginLocal(ctx context.Context, clientID, clientSecret, redirectURL string) (*AuthResult, error) { |
56 | 83 | addr := fmt.Sprintf("127.0.0.1:%d", DefaultCallbackPort) |
57 | 84 | listener, err := net.Listen("tcp", addr) |
58 | 85 | if err != nil { |
59 | 86 | return nil, errfmt.Wrap(errfmt.ExitError, fmt.Sprintf("cannot listen on port %d — is another qbo login running?", DefaultCallbackPort), err) |
60 | 87 | } |
61 | | - redirectURL := fmt.Sprintf("http://localhost:%d/callback", DefaultCallbackPort) |
62 | 88 |
|
63 | 89 | cfg := OAuthConfig(clientID, clientSecret, redirectURL) |
64 | 90 | state := GenerateState() |
@@ -112,6 +138,47 @@ func LoginInteractive(ctx context.Context, clientID, clientSecret string) (*Auth |
112 | 138 | } |
113 | 139 | } |
114 | 140 |
|
| 141 | +func loginManual(ctx context.Context, clientID, clientSecret, redirectURL string) (*AuthResult, error) { |
| 142 | + cfg := OAuthConfig(clientID, clientSecret, redirectURL) |
| 143 | + state := GenerateState() |
| 144 | + |
| 145 | + authURL := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline) |
| 146 | + fmt.Fprintf(os.Stderr, "Open this URL in your browser:\n\n %s\n\n", authURL) |
| 147 | + fmt.Fprintf(os.Stderr, "After authorizing, paste the full callback URL here:\n") |
| 148 | + |
| 149 | + scanner := bufio.NewScanner(os.Stdin) |
| 150 | + if !scanner.Scan() { |
| 151 | + return nil, errfmt.New(errfmt.ExitAuth, "no input received") |
| 152 | + } |
| 153 | + callbackURL := strings.TrimSpace(scanner.Text()) |
| 154 | + if callbackURL == "" { |
| 155 | + return nil, errfmt.New(errfmt.ExitAuth, "empty callback URL") |
| 156 | + } |
| 157 | + |
| 158 | + u, err := url.Parse(callbackURL) |
| 159 | + if err != nil { |
| 160 | + return nil, errfmt.Wrap(errfmt.ExitAuth, "invalid callback URL", err) |
| 161 | + } |
| 162 | + |
| 163 | + q := u.Query() |
| 164 | + if q.Get("state") != state { |
| 165 | + return nil, errfmt.New(errfmt.ExitAuth, "state mismatch — possible CSRF attack or stale URL") |
| 166 | + } |
| 167 | + |
| 168 | + code := q.Get("code") |
| 169 | + realmID := q.Get("realmId") |
| 170 | + if code == "" || realmID == "" { |
| 171 | + return nil, errfmt.New(errfmt.ExitAuth, "callback URL missing code or realmId parameters") |
| 172 | + } |
| 173 | + |
| 174 | + token, err := cfg.Exchange(ctx, code) |
| 175 | + if err != nil { |
| 176 | + return nil, errfmt.Wrap(errfmt.ExitAuth, "token exchange failed", err) |
| 177 | + } |
| 178 | + |
| 179 | + return &AuthResult{Token: token, RealmID: realmID}, nil |
| 180 | +} |
| 181 | + |
115 | 182 | func openBrowser(url string) { |
116 | 183 | var cmd *exec.Cmd |
117 | 184 | switch runtime.GOOS { |
|
0 commit comments