Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
500428b
Split auth issuer from data API origin
Soph May 20, 2026
901889f
Add provider routing table for v1/v2 device-flow surfaces
Soph May 20, 2026
80f5281
Route auth-token management via configurable path + reject empty bearers
Soph May 20, 2026
49dca72
Integrate auth-go library: deviceflow shim + tokenstore.Store + RFC 8…
Soph May 20, 2026
5b5f6bd
Route data-API callers through TokenForResource
Soph May 20, 2026
2365cde
Validate received login token before persisting (alg:none, iss, exp)
Soph May 20, 2026
a9f5f5b
Thread --insecure-http-auth through to deviceflow.Client
Soph May 20, 2026
eabebe7
Canonicalise AuthBaseURL to match tokenmanager's internal normalisation
Soph May 20, 2026
8e908d7
Accept all opaque tokens in validateReceivedToken, reject only alg:none
Soph May 20, 2026
cebf48a
Omit Authorization header when bearer token is empty
Soph May 20, 2026
3099bcc
Thread --insecure-http-auth through to the tokenmanager
Soph May 20, 2026
6f6fcdc
Address Copilot review: strip path from TokenForResource resources, c…
Soph May 20, 2026
a2afc47
Match v2 provider paths to OIDC-standard discovery; route API tokens …
Soph May 21, 2026
d792e11
Open verification_uri_complete on login when the AS supplies one
Soph May 22, 2026
61671af
Merge origin/main into soph/auth-go; bump auth-go v0.1.1-... → v0.3.3
khaong May 24, 2026
de4d3bd
Bump auth-go v0.3.3 → v0.3.4
khaong May 25, 2026
33daf20
recap: surface token-resolution errors verbatim instead of mislabelling
khaong May 25, 2026
d1f0497
search: add --insecure-http-auth flag for split-host local-dev parity
khaong May 25, 2026
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
2 changes: 1 addition & 1 deletion cmd/entire/cli/activity_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func newActivityCmd() *cobra.Command {
}

func runActivity(ctx context.Context, w, errW io.Writer) error {
client, err := NewAuthenticatedAPIClient(false)
client, err := NewAuthenticatedAPIClient(ctx, false)
Comment thread
khaong marked this conversation as resolved.
if err != nil {
fmt.Fprintln(errW, "Not logged in. Run 'entire login' to authenticate.")
return NewSilentError(err)
Expand Down
40 changes: 32 additions & 8 deletions cmd/entire/cli/api/auth_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package api

import (
"context"
"errors"
"fmt"
"net/url"
)

// Token is a single API token row returned by GET /api/v1/auth/tokens.
// Token is a single API token row returned by the auth-tokens endpoint.
// Plaintext token values are never returned by the server — only metadata.
type Token struct {
ID string `json:"id"`
Expand All @@ -18,15 +19,32 @@ type Token struct {
CreatedAt string `json:"created_at"`
}

// TokensResponse is the envelope returned by GET /api/v1/auth/tokens.
// TokensResponse is the envelope returned by the list endpoint.
type TokensResponse struct {
Tokens []Token `json:"tokens"`
}

// errAuthTokensPathUnset surfaces when an auth-tokens method is called
// on a Client that wasn't given a base path. Construct via
// NewClientWithBaseURL(...).WithAuthTokensPath(...) — the active path
// lives in cmd/entire/cli/auth.CurrentProvider().AuthTokensPath, the
// single source of truth for provider-version routing.
var errAuthTokensPathUnset = errors.New("api: auth-tokens path is unset (call (*Client).WithAuthTokensPath before list/revoke)")

func (c *Client) authTokensBasePath() (string, error) {
if c.authTokensPath == "" {
return "", errAuthTokensPathUnset
}
return c.authTokensPath, nil
}

// ListTokens returns the authenticated user's non-expired API tokens.
// Backed by GET /api/v1/auth/tokens.
func (c *Client) ListTokens(ctx context.Context) ([]Token, error) {
resp, err := c.Get(ctx, "/api/v1/auth/tokens")
base, err := c.authTokensBasePath()
if err != nil {
return nil, fmt.Errorf("list tokens: %w", err)
}
resp, err := c.Get(ctx, base)
if err != nil {
return nil, fmt.Errorf("list tokens: %w", err)
}
Expand All @@ -44,9 +62,12 @@ func (c *Client) ListTokens(ctx context.Context) ([]Token, error) {
}

// RevokeCurrentToken revokes the bearer token used to authenticate this client.
// Backed by DELETE /api/v1/auth/tokens/current.
func (c *Client) RevokeCurrentToken(ctx context.Context) error {
resp, err := c.Delete(ctx, "/api/v1/auth/tokens/current")
base, err := c.authTokensBasePath()
if err != nil {
return fmt.Errorf("revoke current token: %w", err)
}
resp, err := c.Delete(ctx, base+"/current")
if err != nil {
return fmt.Errorf("revoke current token: %w", err)
}
Expand All @@ -59,9 +80,12 @@ func (c *Client) RevokeCurrentToken(ctx context.Context) error {
}

// RevokeToken revokes the API token with the given id.
// Backed by DELETE /api/v1/auth/tokens/{id}.
func (c *Client) RevokeToken(ctx context.Context, id string) error {
resp, err := c.Delete(ctx, "/api/v1/auth/tokens/"+url.PathEscape(id))
base, err := c.authTokensBasePath()
if err != nil {
return fmt.Errorf("revoke token %s: %w", id, err)
}
resp, err := c.Delete(ctx, base+"/"+url.PathEscape(id))
if err != nil {
return fmt.Errorf("revoke token %s: %w", id, err)
}
Expand Down
12 changes: 6 additions & 6 deletions cmd/entire/cli/api/auth_tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestClient_RevokeCurrentToken_SendsDeleteWithBearer(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens")
c.baseURL = server.URL

if err := c.RevokeCurrentToken(context.Background()); err != nil {
Expand Down Expand Up @@ -51,7 +51,7 @@ func TestClient_RevokeCurrentToken_ReturnsHTTPErrorOn401(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens")
c.baseURL = server.URL

err := c.RevokeCurrentToken(context.Background())
Expand Down Expand Up @@ -87,7 +87,7 @@ func TestClient_ListTokens_DecodesResponse(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens")
c.baseURL = server.URL

tokens, err := c.ListTokens(context.Background())
Expand Down Expand Up @@ -129,7 +129,7 @@ func TestClient_ListTokens_ReturnsHTTPErrorOn401(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens")
c.baseURL = server.URL

_, err := c.ListTokens(context.Background())
Expand All @@ -155,7 +155,7 @@ func TestClient_RevokeToken_SendsDeleteWithEscapedID(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens")
c.baseURL = server.URL

// Use an id that needs URL escaping to verify we don't blindly concat.
Expand Down Expand Up @@ -184,7 +184,7 @@ func TestClient_RevokeToken_ReturnsErrorBody(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c := NewClient("tok").WithAuthTokensPath("/api/v1/auth/tokens")
c.baseURL = server.URL

err := c.RevokeToken(context.Background(), "missing")
Expand Down
79 changes: 77 additions & 2 deletions cmd/entire/cli/api/base_url.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ const (

// BaseURLEnvVar overrides the Entire API origin for local development.
BaseURLEnvVar = "ENTIRE_API_BASE_URL"

// AuthBaseURLEnvVar overrides only the auth/login origin (device flow,
// auth-tokens management, keyring key). Falls back to BaseURLEnvVar when
// unset, which is the right behavior for single-host deployments. Split
// hosts (e.g. auth on us.console.partial.to, data on partial.to) set
// both.
AuthBaseURLEnvVar = "ENTIRE_AUTH_BASE_URL"

schemeHTTP = "http"
schemeHTTPS = "https"
)

// BaseURL returns the effective Entire API base URL.
Expand All @@ -29,6 +39,27 @@ func BaseURL() string {
return DefaultBaseURL
}

// AuthBaseURL returns the origin used for the device-flow login, auth-token
// management endpoints, and the keyring key under which the bearer token is
// stored. ENTIRE_AUTH_BASE_URL takes precedence; otherwise it falls back to
// BaseURL() so single-host deployments keep working unchanged.
//
// The result is canonicalised — lowercased scheme/host, default port stripped,
// path/query/fragment dropped, trailing slash collapsed — so the value that
// flows into store.SaveToken keys matches what tokenmanager.New emits after
// its own NormalizeOriginURL pass. Without this, a user setting
// ENTIRE_AUTH_BASE_URL=https://AUTH.example.com:443/ would log in successfully
// (saved under the raw form) but every subsequent data-API command would
// resolve "not logged in" because the manager probes under the normalised
// "https://auth.example.com".
func AuthBaseURL() string {
raw := strings.TrimSpace(os.Getenv(AuthBaseURLEnvVar))
if raw == "" {
raw = BaseURL()
}
return NormalizeOriginURL(raw)
}

// ResolveURL joins an API-relative path against the effective base URL.
func ResolveURL(path string) (string, error) {
return ResolveURLFromBase(BaseURL(), path)
Expand All @@ -42,7 +73,7 @@ func ResolveURLFromBase(baseURL, path string) (string, error) {
return "", fmt.Errorf("parse base URL: %w", err)
}

if base.Scheme != "http" && base.Scheme != "https" {
if base.Scheme != schemeHTTP && base.Scheme != schemeHTTPS {
return "", fmt.Errorf("unsupported base URL scheme %q (must be http or https)", base.Scheme)
}

Expand All @@ -62,7 +93,7 @@ func RequireSecureURL(baseURL string) error {
return fmt.Errorf("parse base URL: %w", err)
}

if u.Scheme == "http" {
if u.Scheme == schemeHTTP {
return ErrInsecureHTTP
}

Expand All @@ -72,3 +103,47 @@ func RequireSecureURL(baseURL string) error {
func normalizeBaseURL(raw string) string {
return strings.TrimRight(strings.TrimSpace(raw), "/")
}

// NormalizeOriginURL canonicalises an origin URL the same way auth-go's
// tokenmanager does internally: lowercase scheme/host, default port stripped
// (80 for http, 443 for https), path/query/fragment dropped, trailing slash
// collapsed. On parse failure, raw is returned unchanged so non-URL audience
// values still compare byte-for-byte.
//
// Mirrors auth-go's internal/oauthhttp.NormalizeOriginURL so the value the
// CLI hands to the manager as Issuer survives the manager's own normalisation
// pass byte-for-byte — see AuthBaseURL.
func NormalizeOriginURL(raw string) string {
trimmed := strings.TrimSpace(raw)
u, err := url.Parse(trimmed)
if err != nil || u.Scheme == "" || u.Host == "" {
return trimmed
}
scheme := strings.ToLower(u.Scheme)
hostname := strings.ToLower(u.Hostname())
port := u.Port()
dropPort := port == "" ||
(scheme == schemeHTTP && port == "80") ||
(scheme == schemeHTTPS && port == "443")

out := url.URL{Scheme: scheme}
switch {
case dropPort && strings.Contains(hostname, ":"):
out.Host = "[" + hostname + "]"
case dropPort:
out.Host = hostname
case strings.Contains(hostname, ":"):
out.Host = "[" + hostname + "]:" + port
default:
out.Host = hostname + ":" + port
}
return out.String()
}

// OriginOnly is a backwards-compatible alias for NormalizeOriginURL.
// Callers reading raw URLs (e.g. ENTIRE_SEARCH_URL) and feeding them into
// tokenmanager.TokenRequest.Resource use this to strip path/query/fragment
// before the lib's stricter origin-only validator runs.
func OriginOnly(raw string) string {
return NormalizeOriginURL(raw)
}
53 changes: 53 additions & 0 deletions cmd/entire/cli/api/base_url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,59 @@ func TestRequireSecureURL_RejectsHTTP(t *testing.T) {
}
}

func TestAuthBaseURL_FallsBackToBaseURL(t *testing.T) {
t.Setenv(BaseURLEnvVar, "https://example.test")
t.Setenv(AuthBaseURLEnvVar, "")

if got := AuthBaseURL(); got != "https://example.test" {
t.Fatalf("AuthBaseURL() = %q, want fallback to BaseURL", got)
}
}

func TestAuthBaseURL_OverrideTakesPrecedence(t *testing.T) {
t.Setenv(BaseURLEnvVar, "https://data.example.test")
t.Setenv(AuthBaseURLEnvVar, " https://auth.example.test/ ")

if got := AuthBaseURL(); got != "https://auth.example.test" {
t.Fatalf("AuthBaseURL() = %q, want trimmed/normalized override", got)
}
}

func TestAuthBaseURL_CanonicalisesScheme_HostCase_DefaultPort(t *testing.T) {
// Same canonicalisation tokenmanager.New applies internally — must match
// or the keyring key login wrote diverges from the one the manager later
// reads, producing spurious "not logged in" errors on every data-API call.
t.Setenv(AuthBaseURLEnvVar, "HTTPS://AUTH.example.com:443/")

if got := AuthBaseURL(); got != "https://auth.example.com" {
t.Fatalf("AuthBaseURL() = %q, want canonicalised origin", got)
}
}

func TestNormalizeOriginURL(t *testing.T) {
t.Parallel()

cases := []struct {
in, want string
}{
{"https://example.com", "https://example.com"},
{"https://example.com/", "https://example.com"},
{"HTTPS://Example.COM", "https://example.com"},
{"https://example.com:443", "https://example.com"},
{"http://example.com:80", "http://example.com"},
{"https://example.com:8443", "https://example.com:8443"},
{"https://example.com/some/path?q=1#frag", "https://example.com"},
{" https://example.com/ ", "https://example.com"},
{"not a url", "not a url"},
{"", ""},
}
for _, tc := range cases {
if got := NormalizeOriginURL(tc.in); got != tc.want {
t.Errorf("NormalizeOriginURL(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

func TestResolveURL(t *testing.T) {
t.Setenv(BaseURLEnvVar, "http://localhost:8787/")

Expand Down
44 changes: 41 additions & 3 deletions cmd/entire/cli/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,58 @@ const (
type Client struct {
httpClient *http.Client
baseURL string

// authTokensPath is the base path for the auth-tokens management
// endpoints (list / revoke). Set via WithAuthTokensPath when the
// client targets the auth host. Empty for data-API-only clients;
// auth-tokens methods error out if called against an empty path.
authTokensPath string
}

// WithAuthTokensPath sets the base path used by ListTokens,
// RevokeCurrentToken, and RevokeToken. The path is supplied by the
// auth shim from auth.CurrentProvider().AuthTokensPath, which is the
// single source of truth for provider-version routing — the api
// package no longer reads ENTIRE_AUTH_PROVIDER_VERSION itself.
//
// Returns the receiver for chaining at construction:
//
// c := api.NewClientWithBaseURL(token, base).WithAuthTokensPath(p)
func (c *Client) WithAuthTokensPath(path string) *Client {
c.authTokensPath = path
return c
}

// NewClient creates a new authenticated API client with an explicit bearer token.
// NewClient creates a new authenticated API client with an explicit bearer
// token, targeting the data API base URL (BaseURL()).
func NewClient(token string) *Client {
return NewClientWithBaseURL(token, BaseURL())
}

// NewClientWithBaseURL creates a new authenticated API client targeting an
// explicit base URL. Use this for endpoints that live on the auth host (e.g.
// auth-token management) when ENTIRE_AUTH_BASE_URL splits the auth origin
// from the data API origin.
func NewClientWithBaseURL(token, baseURL string) *Client {
return &Client{
httpClient: &http.Client{
Transport: &bearerTransport{
token: token,
base: http.DefaultTransport,
},
},
baseURL: BaseURL(),
baseURL: baseURL,
}
}

// bearerTransport is an http.RoundTripper that injects the Authorization header.
//
// When token is empty, the Authorization header is omitted (rather than sent
// as a malformed "Authorization: Bearer "). This supports endpoints like
// recap that deliberately want the unauthenticated request to reach the
// server so it can return a typed 401 — callers that want a local fast-fail
// for missing auth should check ErrNotLoggedIn at construction time, not
// rely on the transport.
type bearerTransport struct {
token string
base http.RoundTripper
Expand All @@ -45,7 +81,9 @@ type bearerTransport struct {
func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid mutating the caller's request.
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+t.token)
if t.token != "" {
r.Header.Set("Authorization", "Bearer "+t.token)
}
r.Header.Set("User-Agent", userAgent)
if r.Header.Get("Accept") == "" {
r.Header.Set("Accept", "application/json")
Expand Down
Loading
Loading