Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/content/docs/security/authentication/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ The gateway supports two authentication channels:
5. After MFA, the browser is redirected to the CLI's loopback listener with a single-use login code
6. The CLI redeems the login code with its code verifier and receives a session token
7. Session token stored in `~/.config/rack-gateway/config.json`
8. The CLI sends the browser on to the gateway's result page (success, or the reason the login failed)

See [OAuth Flow](/security/authentication/oauth-flow/) for details.

Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/security/authentication/oauth-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ sequenceDiagram

CLI->>Gateway: POST /api/v1/auth/cli/complete (login_code + CLI code_verifier)
Gateway-->>CLI: Session token
CLI-->>Browser: Redirect to /app/cli/auth/success (or /app/cli/auth/error?error=<code>)
```

### What protects the login
Expand All @@ -130,6 +131,10 @@ sequenceDiagram
- **Single-use login code.** The login code is 256 bits of randomness, stored only as a SHA-256 hash, valid
for 2 minutes, issued once per login, and deleted when redeemed (a failed redemption also burns it).
- **CLI PKCE.** Redeeming the login code also requires the CLI's code verifier, which never leaves the CLI.
- **Honest result page.** The CLI holds the browser on its callback until it has redeemed the login code and
saved the session, then redirects it (with `Referrer-Policy: no-referrer`, so the code isn't leaked as a
referrer) to the gateway's success or error page. The error page only shows fixed messages for known error
codes, so a crafted link can't put its own text on it.
- **Time limit.** The whole login must finish within 10 minutes.
- **First-factor enrollment.** A user with no MFA factor can enroll one during the login, but only in the
browser bound to the login.
Expand Down
5 changes: 4 additions & 1 deletion docs/src/content/docs/user-guide/cli/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ The `rack-gateway` CLI uses OAuth to authenticate you with the gateway. This pag

4. **Return to terminal**

After successful authentication, you can close the browser. The CLI receives a session token and stores it locally.
After successful authentication, the browser shows **Authentication Complete** and you can close it. The CLI
receives a session token and stores it locally.

5. **Verify connection**
```bash
Expand All @@ -53,6 +54,7 @@ sequenceDiagram
Gateway-->>CLI: 5. Browser redirected to 127.0.0.1 with a single-use login code
CLI->>Gateway: 6. Redeem login code + PKCE verifier
Gateway-->>CLI: 7. Session token
CLI-->>Gateway: 8. Browser sent to the login result page
```

The CLI:
Expand All @@ -62,6 +64,7 @@ The CLI:
local listener with a single-use login code
4. Redeems the code together with its PKCE verifier for a session token
5. Stores the session token in your config file
6. Sends the browser on to the gateway's result page: **Authentication Complete**, or what went wrong

The whole login must finish within 10 minutes. The browser must be on the **same machine** as the CLI,
because the login code is delivered to `127.0.0.1` (see [Logging in from a remote host](#logging-in-from-a-remote-host)).
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ The integration tests create backups of the real Convox CLI configuration to pre
loopback listener with a single-use login code (or `error=<code>`, e.g. `canceled`)
5. Redeem the login code with the verifier at `POST /api/v1/auth/cli/complete` for a session token
6. Token stored in config file
7. The loopback listener holds the browser until then, and finally redirects it to the SPA's
`/app/cli/auth/success` or `/app/cli/auth/error?error=<code>` page. The CLI never renders HTML itself

The browser must be on the same machine as the CLI (remote hosts: `ssh -L <port>:127.0.0.1:<port>`).
The login times out after 10 minutes. A gateway upgrade to this flow needs a matching CLI build.
Expand Down
13 changes: 7 additions & 6 deletions internal/cli/cli_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,22 +47,19 @@ func loginCommandWithFlags(args []string, noOpen bool, authFile string) error {

fmt.Printf("Starting login for rack: %s via gateway: %s\n", rack, gatewayURL)

loginResp, err := runLoopbackLogin(gatewayURL, noOpen, authFile)
loginResp, err := runLoopbackLogin(rack, gatewayURL, noOpen, authFile)
if err != nil {
return err
}

if err := finalizeLogin(rack, loginResp); err != nil {
return err
}

fmt.Printf("✓ Successfully logged in to %s as %s\n", rack, loginResp.Email)
return nil
}

// runLoopbackLogin performs an RFC 8252 loopback login: the browser hands a single-use login code
// back to this process on 127.0.0.1, and only this process holds the PKCE verifier to redeem it.
func runLoopbackLogin(gatewayURL string, noOpen bool, authFile string) (*LoginResponse, error) {
// The session is saved for rack before the browser is shown that the login succeeded.
func runLoopbackLogin(rack, gatewayURL string, noOpen bool, authFile string) (*LoginResponse, error) {
challenge, err := newPKCE()
if err != nil {
return nil, err
Expand Down Expand Up @@ -109,6 +106,10 @@ func runLoopbackLogin(gatewayURL string, noOpen bool, authFile string) (*LoginRe
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
if err := finalizeLogin(rack, loginResp); err != nil {
return nil, err
}
loopback.finish("")
return loginResp, nil
}

Expand Down
75 changes: 48 additions & 27 deletions internal/cli/login_loopback.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ import (
"encoding/base64"
"errors"
"fmt"
"html/template"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
)

Expand All @@ -20,6 +21,14 @@ const loginTimeout = 10 * time.Minute

var errLoginTimedOut = errors.New("login timed out waiting for browser authentication")

// Error codes the CLI itself ends a login with on the gateway's result page (the gateway's own codes
// are passed through). The page maps each code to a message.
const (
loginErrorStateMismatch = "state_mismatch"
loginErrorMissingCode = "missing_code"
loginErrorIncomplete = "cli_incomplete"
)

// pkce holds an RFC 7636 code verifier and its S256 challenge.
type pkce struct {
verifier string
Expand Down Expand Up @@ -50,12 +59,20 @@ type loopbackResult struct {

// loopbackServer receives the browser redirect that carries the single-use login code (RFC 8252).
// It only listens on 127.0.0.1 and only accepts the redirect carrying this login's state.
//
// The browser is held on the callback until the terminal has redeemed the login code and saved the
// session, then sent to the gateway's result page, so the browser shows how the login really ended.
type loopbackServer struct {
redirectURI string
state string
gatewayURL string
server *http.Server
results chan loopbackResult

finishOnce sync.Once
finished chan struct{}
// outcome is the result page error code ("" for success); set before finished is closed.
outcome string
}

func startLoopbackServer(state, gatewayURL string) (*loopbackServer, error) {
Expand All @@ -74,6 +91,7 @@ func startLoopbackServer(state, gatewayURL string) (*loopbackServer, error) {
state: state,
gatewayURL: gatewayURL,
results: make(chan loopbackResult, 1),
finished: make(chan struct{}),
}
mux := http.NewServeMux()
mux.HandleFunc("/callback", s.handleCallback)
Expand All @@ -86,24 +104,26 @@ func (s *loopbackServer) handleCallback(w http.ResponseWriter, r *http.Request)
query := r.URL.Query()
if subtle.ConstantTimeCompare([]byte(query.Get("state")), []byte(s.state)) != 1 {
// Not our login: ignore it and keep waiting for the real redirect.
s.writePage(w, http.StatusBadRequest, "Login link mismatch",
"This login does not match the rack-gateway login waiting in your terminal.")
s.redirectToResult(w, r, loginErrorStateMismatch)
return
}
if errCode := strings.TrimSpace(query.Get("error")); errCode != "" {
message := loginErrorMessage(errCode)
s.writePage(w, http.StatusOK, "Login failed", message+". Return to your terminal.")
s.deliver(loopbackResult{err: errors.New(message)})
s.deliver(loopbackResult{err: errors.New(loginErrorMessage(errCode))})
s.redirectToResult(w, r, errCode)
return
}
code := strings.TrimSpace(query.Get("code"))
if code == "" {
s.writePage(w, http.StatusBadRequest, "Login failed", "The login code is missing. Return to your terminal.")
s.deliver(loopbackResult{err: errors.New("the gateway did not return a login code")})
s.redirectToResult(w, r, loginErrorMissingCode)
return
}
s.writePage(w, http.StatusOK, "Login approved", "You can close this tab and return to your terminal.")
s.deliver(loopbackResult{code: code})
select {
case <-s.finished:
s.redirectToResult(w, r, s.outcome)
case <-r.Context().Done():
}
}

func (s *loopbackServer) deliver(result loopbackResult) {
Expand All @@ -125,33 +145,34 @@ func (s *loopbackServer) wait(timeout time.Duration) (string, error) {
}
}

// finish records how the login ended (errorCode "" for success) and releases the browser waiting on
// the callback. Only the first call counts.
func (s *loopbackServer) finish(errorCode string) {
s.finishOnce.Do(func() {
s.outcome = errorCode
close(s.finished)
})
}

func (s *loopbackServer) close() {
// A browser still waiting here means the login ended without being finished.
s.finish(loginErrorIncomplete)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = s.server.Shutdown(ctx)
}

var loopbackPage = template.Must(template.New("loopback").Parse(`<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>{{.Title}}</title></head>
<body>
<h1>{{.Title}}</h1>
<p>{{.Message}}</p>
<p><a href="{{.WebURL}}">Open the Rack Gateway web UI</a></p>
</body>
</html>`))

func (s *loopbackServer) writePage(w http.ResponseWriter, status int, title, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Security-Policy", "default-src 'none'")
// redirectToResult sends the browser to the gateway's CLI login result page (errorCode "" for success).
func (s *loopbackServer) redirectToResult(w http.ResponseWriter, r *http.Request, errorCode string) {
target := buildGatewayAPIURL(s.gatewayURL, "/app/cli/auth/success")
if errorCode != "" {
query := url.Values{"error": {errorCode}}
target = buildGatewayAPIURL(s.gatewayURL, "/app/cli/auth/error") + "?" + query.Encode()
}
// The callback URL carries the login code, so it must not leak as a referrer.
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = loopbackPage.Execute(w, map[string]string{
"Title": title,
"Message": message,
"WebURL": buildGatewayAPIURL(s.gatewayURL, "/app/"),
})
http.Redirect(w, r, target, http.StatusSeeOther)
}

// loginErrorMessages maps the gateway's login error codes to messages for the terminal.
Expand Down
Loading
Loading