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
9 changes: 8 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test test-unit test-smoke coverage fmt fmt-check vet lint tidy tidy-check \
.PHONY: build test test-unit test-smoke preview-callback coverage fmt fmt-check vet lint tidy tidy-check \
race-test vuln secrets replace-check check-toolchain check security \
release-check release bench bench-save bench-compare \
check-surface check-surface-compat tools clean install help
Expand All @@ -23,6 +23,7 @@ help:
@echo " make test-unit Run unit tests"
@echo " make test Alias for test-unit"
@echo " make test-smoke Run smoke tests against a live server"
@echo " make preview-callback Preview the OAuth callback screens in a browser"
@echo " make coverage Run cross-package coverage and enforce the 70.8% floor"
@echo " make clean Remove build artifacts"
@echo " make tidy Tidy dependencies"
Expand Down Expand Up @@ -81,6 +82,12 @@ coverage: check-toolchain
@./scripts/coverage-summary.sh $(COVERAGE_PROFILE) $(COVERAGE_FUNCTIONS) $(COVERAGE_PACKAGES)
@./scripts/check-coverage.sh $(COVERAGE_PROFILE) $(COVERAGE_FLOOR)

# Serve the OAuth callback screens for visual review at http://127.0.0.1:9999.
# Pages re-render from disk on every request: edit internal/auth/callback*.html
# and refresh the browser. Ctrl-C to stop.
preview-callback: check-toolchain
PREVIEW=1 go test -run TestPreviewCallbackPages ./internal/auth/ -count=1 -v -timeout=0

# Run smoke tests against a live HEY server.
# Requires: a running server (default http://app.hey.localhost:3003) and Chrome.
# Override defaults: make test-smoke HEY_SMOKE_BASE_URL=... HEY_SMOKE_EMAIL=... HEY_SMOKE_PASSWORD=...
Expand Down
42 changes: 29 additions & 13 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,29 +307,45 @@ func (m *Manager) waitForCallback(ctx context.Context, expectedState, authURL, c
})
}

server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")

state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
errParam := r.URL.Query().Get("error")

if errParam != "" {
errCh <- fmt.Errorf("OAuth error: %s", errParam)
fmt.Fprint(w, "<html><body><h1>Authentication failed</h1><p>You can close this window.</p></body></html>")
// Sends are non-blocking: only the first request's result matters,
// and waitForCallback stops reading once it returns.
fail := func(err error, page string) {
select {
case errCh <- err:
default:
}
fmt.Fprint(w, page)
shutdownServer()
return
}

if state != expectedState {
errCh <- fmt.Errorf("state mismatch: CSRF protection failed")
fmt.Fprint(w, "<html><body><h1>Authentication failed</h1><p>State mismatch.</p></body></html>")
switch {
case state != expectedState:
fail(fmt.Errorf("state mismatch: CSRF protection failed"), callbackInvalid)
case errParam == "access_denied":
fail(fmt.Errorf("OAuth error: %s", errParam), callbackDenied)
case errParam != "":
fail(fmt.Errorf("OAuth error: %s", errParam), callbackError)
case code == "":
fail(fmt.Errorf("OAuth callback missing authorization code"), callbackError)
default:
select {
case codeCh <- code:
default:
}
fmt.Fprint(w, callbackSuccess)
shutdownServer()
return
}

codeCh <- code
fmt.Fprint(w, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
shutdownServer()
})
server.Handler = mux

go server.Serve(listener) //nolint:errcheck

Expand Down
16 changes: 13 additions & 3 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,13 @@ func TestWaitForCallback(t *testing.T) {
query string
wantCode string
wantError string
wantBody string
}{
{name: "success", query: "?state=expected&code=authorization-code", wantCode: "authorization-code"},
{name: "OAuth error", query: "?error=access_denied", wantError: "OAuth error: access_denied"},
{name: "state mismatch", query: "?state=wrong&code=authorization-code", wantError: "state mismatch"},
{name: "success", query: "?state=expected&code=authorization-code", wantCode: "authorization-code", wantBody: "Authorization successful"},
{name: "access denied", query: "?state=expected&error=access_denied", wantError: "OAuth error: access_denied", wantBody: "You denied access"},
{name: "OAuth error", query: "?state=expected&error=server_error", wantError: "OAuth error: server_error", wantBody: "Authorization failed"},
{name: "missing code", query: "?state=expected", wantError: "missing authorization code", wantBody: "Authorization failed"},
{name: "state mismatch", query: "?state=wrong&code=authorization-code", wantError: "state mismatch", wantBody: "authorization link is invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down Expand Up @@ -249,7 +252,14 @@ func TestWaitForCallback(t *testing.T) {
if err != nil {
t.Fatalf("GET callback: %v", err)
}
body, err := io.ReadAll(response.Body)
_ = response.Body.Close()
if err != nil {
t.Fatalf("reading callback response: %v", err)
}
if !strings.Contains(string(body), tt.wantBody) {
t.Errorf("response body does not contain %q", tt.wantBody)
}
var got result
select {
case got = <-resultCh:
Expand Down
47 changes: 47 additions & 0 deletions internal/auth/callback.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package auth

import (
"bytes"
"embed"
"fmt"
"io/fs"
"text/template"
)

//go:embed *.html
var callbackFS embed.FS

var (
callbackSuccess = mustRenderCallback("callback_success.html")
callbackError = mustRenderCallback("callback_error.html")
callbackDenied = mustRenderCallback("callback_denied.html")
callbackInvalid = mustRenderCallback("callback_invalid.html")
)

func mustRenderCallback(filename string) string {
page, err := renderCallback(callbackFS, filename)
if err != nil {
panic(err)
}
return page
}

// renderCallback renders one callback screen: all templates are parsed
// together so {{template "hey_logo.html"}} references resolve, the named
// content page is rendered, then wrapped in the outer shell (callback.html).
// Taking fs.FS lets the preview server render from disk.
func renderCallback(fsys fs.FS, filename string) (string, error) {
tmpl, err := template.ParseFS(fsys, "*.html")
if err != nil {
return "", fmt.Errorf("parsing callback templates: %w", err)
}
var content bytes.Buffer
if err := tmpl.ExecuteTemplate(&content, filename, nil); err != nil {
return "", fmt.Errorf("rendering callback content %s: %w", filename, err)
}
var page bytes.Buffer
if err := tmpl.ExecuteTemplate(&page, "callback.html", content.String()); err != nil {
return "", fmt.Errorf("rendering callback shell: %w", err)
}
return page.String(), nil
}
156 changes: 156 additions & 0 deletions internal/auth/callback.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize HEY CLI</title>
<style>
:root {
color-scheme: light dark;

--rgb-ink: 35, 28, 51;
--rgb-white: 255, 255, 255;
--rgb-background: 237, 234, 230;
--rgb-black: 0, 0, 0;
--rgb-green: 41, 152, 80;
--rgb-orange: 248, 121, 23;
--rgb-red: 201, 36, 0;
--rgb-purple: 85, 34, 250;

--color-background: rgb(var(--rgb-background));
--color-text: rgb(var(--rgb-ink));
--color-text-subtle: rgba(var(--rgb-ink), 0.66);
--color-positive: rgb(var(--rgb-green));
--color-alert: rgb(var(--rgb-orange));
--color-negative: rgb(var(--rgb-red));
--color-sheet: rgb(var(--rgb-white));
--color-shadow: rgba(var(--rgb-ink), 0.25);
}

@media (prefers-color-scheme: dark) {
:root {
--rgb-ink: 236, 233, 230;
--rgb-background: 27, 39, 51;
--rgb-green: 105, 240, 174;
--rgb-orange: 255, 184, 92;
--rgb-red: 255, 120, 120;
--rgb-purple: 134, 126, 255;

--color-sheet: rgb(38, 47, 58);
--color-shadow: rgba(0, 0, 0, 0.25);
}
}

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
align-items: center;
background: var(--color-background);
color: var(--color-text);
display: flex;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 17px;
justify-content: center;
line-height: 1.4;
min-height: 100vh;
}

.card {
background: var(--color-sheet);
border-radius: 8px;
box-shadow: 0 0 0 1px rgba(var(--rgb-black), 0.02),
0 0.2em 1.6em -0.8em rgba(var(--rgb-black), 0.2),
0 0.4em 2.4em -1.0em rgba(var(--rgb-black), 0.3),
0 0.4em 0.8em -1.2em rgba(var(--rgb-black), 0.4),
0 0.8em 1.2em -1.6em rgba(var(--rgb-black), 0.5),
0 1.2em 1.6em -2.0em rgba(var(--rgb-black), 0.6);
inline-size: 100%;
max-inline-size: 460px;
padding: 32px 25px;
text-align: center;
}

.logo {
display: inline-block;
margin-block-end: 12px;
position: relative;
}

.logo svg {
block-size: auto;
display: block;
inline-size: 120px;
}

.logo .logo-dark { display: none; }

@media (prefers-color-scheme: dark) {
.logo .logo-light { display: none; }
.logo .logo-dark { display: block; }
}

.badge {
align-items: center;
block-size: 25px;
border: 2px solid var(--color-sheet);
border-radius: 50%;
bottom: -6px;
color: var(--color-sheet);
display: flex;
inline-size: 25px;
inset-inline-end: -14px;
place-items: center;
position: absolute;
}

.badge svg {
block-size: 14px;
inline-size: 14px;
margin: auto;
}

.badge-success { background: var(--color-positive); }
.badge-error { background: var(--color-negative); }

.badge-warning {
background: var(--color-alert);

svg {
block-size: 18px;
inline-size: 18px;
}
}

h1 {
font-size: 24px;
font-weight: 700;
line-height: 1.2;
margin: 0 0 4px;
}

p {
color: var(--color-text-subtle);
font-size: 16px;
margin: 0;
}

code {
background: rgba(var(--rgb-purple), 0.1);
border-radius: 0.5em;
color: rgb(var(--rgb-purple));
font-family: monospace;
font-weight: 500;
padding-inline: 0.5ch;
}
</style>
</head>
<body>
<div class="card">
{{.}}
</div>
</body>
</html>
10 changes: 10 additions & 0 deletions internal/auth/callback_denied.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div class="logo">
{{template "hey_logo.html"}}
<div class="badge badge-warning">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 4h.01"/>
</svg>
</div>
</div>
<h1>You denied access to HEY CLI</h1>
<p>If this was unintentional, return to your terminal and try <code>hey auth login</code> again.</p>
10 changes: 10 additions & 0 deletions internal/auth/callback_error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div class="logo">
{{template "hey_logo.html"}}
<div class="badge badge-error">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</div>
</div>
<h1>Authorization failed</h1>
<p>Something went wrong. Please return to your terminal and try <code>hey auth login</code> again.</p>
10 changes: 10 additions & 0 deletions internal/auth/callback_invalid.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div class="logo">
{{template "hey_logo.html"}}
<div class="badge badge-error">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</div>
</div>
<h1>Your authorization link is invalid</h1>
<p>It may have expired. Please return to your terminal and try <code>hey auth login</code> again.</p>
Loading