Skip to content

Commit 638cff0

Browse files
committed
feat: support custom redirect URI for production OAuth
Production Intuit apps don't allow localhost redirect URIs. Add --redirect-uri flag, QBO_REDIRECT_URI env var, and config field to specify a public redirect URI. Non-localhost URIs use a manual flow where the user pastes the callback URL after authorizing.
1 parent 96ddd63 commit 638cff0

6 files changed

Lines changed: 136 additions & 40 deletions

File tree

README.md

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,29 @@ curl -LO https://github.com/voska/qbo-cli/releases/latest/download/qbo_linux_amd
5858
sudo dpkg -i qbo_linux_amd64.deb
5959
```
6060

61+
## Agent Skill
62+
63+
Install as a [Claude Code skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) for AI-assisted QuickBooks workflows:
64+
65+
```bash
66+
npx skills add -g voska/qbo-cli
67+
```
68+
69+
The skill includes setup guidance, usage patterns, troubleshooting, and a full command reference.
70+
71+
## Getting Credentials
72+
73+
You need an Intuit Developer account to get OAuth credentials.
74+
75+
1. Sign up at [developer.intuit.com](https://developer.intuit.com) and create an app.
76+
2. Select **QuickBooks Online and Payments** as the platform.
77+
3. Under **Keys & credentials**, grab your **Client ID** and **Client Secret**.
78+
4. Add a **Redirect URI**`http://localhost:8844/callback` for sandbox, or a public URI for production.
79+
80+
See [Intuit's OAuth 2.0 guide](https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0) for the full walkthrough.
81+
82+
> **Sandbox vs Production:** Development keys only work with sandbox companies. For production, complete Intuit's app assessment and use `--redirect-uri` (or `QBO_REDIRECT_URI`) with a public URI. You can use a tunnel, or any registered domain — after authorizing, paste the callback URL back into the CLI.
83+
6184
## Quick Start
6285

6386
```bash
@@ -84,29 +107,6 @@ qbo list invoices --where "Balance > '0'" --sandbox --json
84107
qbo report profit-and-loss --start-date 2025-01-01 --end-date 2025-12-31 --sandbox
85108
```
86109

87-
## Getting Credentials
88-
89-
You need an Intuit Developer account to get OAuth credentials.
90-
91-
1. Sign up at [developer.intuit.com](https://developer.intuit.com) and create an app.
92-
2. Select **QuickBooks Online and Payments** as the platform.
93-
3. Under **Keys & credentials**, grab your **Client ID** and **Client Secret**.
94-
4. Add `http://localhost:8844/callback` as a **Redirect URI**.
95-
96-
See [Intuit's OAuth 2.0 guide](https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0) for the full walkthrough.
97-
98-
> **Sandbox vs Production:** Development keys only work with sandbox companies. For production, you must complete Intuit's app assessment and use a public redirect URI (not localhost).
99-
100-
## Agent Skill
101-
102-
Install as a [Claude Code skill](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/skills) for AI-assisted QuickBooks workflows:
103-
104-
```bash
105-
npx skills add -g voska/qbo-cli
106-
```
107-
108-
The skill includes setup guidance, usage patterns, troubleshooting, and a full command reference.
109-
110110
## Output Modes
111111

112112
| Flag | Description |

internal/auth/oauth.go

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package auth
22

33
import (
4+
"bufio"
45
"context"
56
"crypto/rand"
67
"encoding/hex"
78
"fmt"
89
"net"
910
"net/http"
11+
"net/url"
1012
"os"
1113
"os/exec"
1214
"runtime"
15+
"strings"
1316

1417
"github.com/voska/qbo-cli/internal/errfmt"
1518
"golang.org/x/oauth2"
@@ -52,13 +55,36 @@ func RefreshAccessToken(ctx context.Context, clientID, clientSecret string, toke
5255

5356
const DefaultCallbackPort = 8844
5457

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) {
5683
addr := fmt.Sprintf("127.0.0.1:%d", DefaultCallbackPort)
5784
listener, err := net.Listen("tcp", addr)
5885
if err != nil {
5986
return nil, errfmt.Wrap(errfmt.ExitError, fmt.Sprintf("cannot listen on port %d — is another qbo login running?", DefaultCallbackPort), err)
6087
}
61-
redirectURL := fmt.Sprintf("http://localhost:%d/callback", DefaultCallbackPort)
6288

6389
cfg := OAuthConfig(clientID, clientSecret, redirectURL)
6490
state := GenerateState()
@@ -112,6 +138,47 @@ func LoginInteractive(ctx context.Context, clientID, clientSecret string) (*Auth
112138
}
113139
}
114140

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+
115182
func openBrowser(url string) {
116183
var cmd *exec.Cmd
117184
switch runtime.GOOS {

internal/cmd/auth.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package cmd
22

33
import (
4-
"fmt"
54
"time"
65

76
"github.com/voska/qbo-cli/internal/auth"
@@ -18,7 +17,8 @@ type AuthCmd struct {
1817
}
1918

2019
type AuthLoginCmd struct {
21-
Manual bool `help:"Print URL for manual copy instead of opening browser."`
20+
Manual bool `help:"Print URL for manual copy instead of opening browser."`
21+
RedirectURI string `name:"redirect-uri" help:"OAuth redirect URI. Required for production (non-localhost). Set via flag, QBO_REDIRECT_URI, or config." env:"QBO_REDIRECT_URI"`
2222
}
2323

2424
func (c *AuthLoginCmd) Run(g *Globals) error {
@@ -28,14 +28,21 @@ func (c *AuthLoginCmd) Run(g *Globals) error {
2828
return errfmt.Config("set QBO_CLIENT_ID and QBO_CLIENT_SECRET before logging in")
2929
}
3030

31+
redirectURI := c.RedirectURI
32+
if redirectURI == "" {
33+
redirectURI = g.Config.ResolveRedirectURI()
34+
}
35+
if redirectURI == "" {
36+
redirectURI = auth.DefaultRedirectURI()
37+
}
38+
3139
if g.CLI.DryRun {
32-
redirectURL := fmt.Sprintf("http://localhost:%d/callback", auth.DefaultCallbackPort)
33-
url := auth.GetAuthURL(clientID, clientSecret, redirectURL, "STATE")
40+
url := auth.GetAuthURL(clientID, clientSecret, redirectURI, "STATE")
3441
output.Hint("[dry-run] would open: %s", url)
3542
return nil
3643
}
3744

38-
result, err := auth.LoginInteractive(g.Ctx, clientID, clientSecret)
45+
result, err := auth.LoginInteractive(g.Ctx, clientID, clientSecret, redirectURI)
3946
if err != nil {
4047
return err
4148
}

internal/config/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Config struct {
2424
Companies []Company `json:"companies,omitempty"`
2525
ClientID string `json:"client_id,omitempty"`
2626
ClientSecret string `json:"client_secret,omitempty"`
27+
RedirectURI string `json:"redirect_uri,omitempty"`
2728
}
2829

2930
func Dir() (string, error) {
@@ -114,3 +115,10 @@ func (c *Config) ResolveClientSecret() string {
114115
}
115116
return c.ClientSecret
116117
}
118+
119+
func (c *Config) ResolveRedirectURI() string {
120+
if v := os.Getenv("QBO_REDIRECT_URI"); v != "" {
121+
return v
122+
}
123+
return c.RedirectURI
124+
}

skills/qbo/SKILL.md

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,29 +28,43 @@ Requires an Intuit Developer account and a QuickBooks app for OAuth credentials.
2828
1. Sign up at https://developer.intuit.com and create an app from the dashboard.
2929
2. Select **QuickBooks Online and Payments** as the platform.
3030
3. Under **Keys & credentials**, find your **Client ID** and **Client Secret**.
31-
4. Add `http://localhost:8844/callback` as a **Redirect URI**.
31+
4. Add a **Redirect URI** (see below).
3232

3333
See [Intuit's OAuth 2.0 guide](https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0) for the full walkthrough.
3434

35-
**Sandbox vs Production:** Development keys only work with sandbox companies. For production access, complete Intuit's app assessment and use a public redirect URI (not localhost). Use a domain routed through a tunnel, or a non-resolving domain — when the redirect errors in the browser, copy the full callback URL and paste it into the terminal.
35+
### Redirect URI Options
36+
37+
**Sandbox** allows `http://localhost:8844/callback` — just register it in the Intuit portal and `qbo auth login` handles everything automatically.
38+
39+
**Production** does not allow localhost. Three options:
40+
41+
1. **Tunnel/funnel address** — Route a domain (e.g. `https://auth.yourdomain.com`) back to your machine. Register it as the redirect URI. Use `--redirect-uri` or set `QBO_REDIRECT_URI`.
42+
2. **Login on the same machine** — If the agent runs on a machine with a browser, use a tunnel so localhost callbacks work.
43+
3. **Non-resolving domain** — Register any domain you own (e.g. `https://yourdomain.com`) as the redirect URI. After authorizing, the browser redirects there with `?code=...&realmId=...&state=...` in the URL. Copy the full URL from the browser and paste it back into `qbo auth login` (or provide it to the agent to exchange manually).
3644

3745
## Setup
3846

3947
```bash
4048
export QBO_CLIENT_ID=your_client_id
4149
export QBO_CLIENT_SECRET=your_client_secret
4250

43-
# Authenticate (opens browser, listens on localhost:8844)
44-
qbo auth login
45-
46-
# Or for sandbox
51+
# Sandbox — uses localhost callback automatically
4752
qbo auth login --sandbox
4853

49-
# Or print the URL without opening a browser (useful for agents/remote)
54+
# Production — specify your redirect URI
55+
qbo auth login --redirect-uri https://yourdomain.com
56+
57+
# Or set it as env var / config so you don't need the flag every time
58+
export QBO_REDIRECT_URI=https://yourdomain.com
59+
qbo auth login
60+
61+
# Print the URL without opening a browser (useful for agents/remote)
5062
qbo auth login --manual
5163
```
5264

53-
Tokens are stored at `~/.config/qbo/tokens/`.
65+
For non-localhost redirect URIs, `qbo auth login` prints the auth URL and prompts you to paste the callback URL after authorizing.
66+
67+
Tokens are stored in the system keychain (macOS Keychain, Windows Credential Manager) with file-based fallback at `~/.config/qbo/tokens/`.
5468

5569
### Verify
5670

skills/qbo/references/COMMANDS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,13 @@ All QBO responses are wrapped. Use `--json` for parsing.
4141
### auth
4242

4343
```bash
44-
qbo auth login [--manual] # OAuth flow on localhost:8844
44+
qbo auth login [--manual] [--redirect-uri URI] # OAuth flow
4545
qbo auth logout # Remove stored credentials
4646
qbo auth status # Show token status and company
4747
qbo auth refresh # Force token refresh
4848
```
4949

50-
OAuth callback listens on `http://localhost:8844/callback`. Register this as your redirect URI in the Intuit developer portal. Tokens stored at `~/.config/qbo/tokens/`.
50+
For sandbox, the default `http://localhost:8844/callback` redirect works automatically. For production, pass `--redirect-uri` (or set `QBO_REDIRECT_URI`) to a registered public URI. Non-localhost URIs use a manual flow: paste the callback URL after authorizing in the browser.
5151

5252
### list
5353

0 commit comments

Comments
 (0)