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
39 changes: 39 additions & 0 deletions internal/httpapi/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,44 @@ func authorizeBearer(authHeader string, verifier *bearerVerifier, workspaceID, r
return claims, nil
}

// relayauthTokenPrefixes are the token-class prefixes that relayauth's
// `/v1/tokens/*` endpoints attach to the access tokens they issue. The
// prefix encodes the token class for clients that route by token type
// (path-scoped vs workspace-scoped vs identity), but it is NOT part of
// the RS256 JWT — the actual JWS string sits AFTER the prefix.
//
// Before this list existed, `parseBearer` split the prefixed bearer on
// `.`, ended up with `relay_pa_<header_b64>` as `parts[0]`, and either
// got an unparseable JSON header or a base64-decode error. Both paths
// returned a 401 "invalid jwt header" / "invalid jwt format" even
// though the underlying JWT was perfectly valid. The relayfile-mount
// daemon (which uses these tokens as Bearer creds) sat in an infinite
// 401 retry loop, which silently broke every proactive-runtime fire
// that depended on it for writeback flushing.
//
// Keep this in sync with the prefixes minted by relayauth's
// `packages/server/src/routes/tokens.ts` (`relay_pa_*` for path-access,
// `relay_ws_*` for workspace, `relay_id_*` for identity).
var relayauthTokenPrefixes = []string{
"relay_pa_",
"relay_ws_",
"relay_id_",
}

// stripRelayauthTokenPrefix removes the leading `relay_<class>_` prefix
// emitted by relayauth's token-mint endpoints so the remainder can be
// parsed as a standard RS256 JWT. Returns the raw input unchanged if no
// known prefix is present (so plain JWTs minted directly via
// `/v1/tokens` with no token-class wrapper still work).
func stripRelayauthTokenPrefix(raw string) string {
for _, prefix := range relayauthTokenPrefixes {
if strings.HasPrefix(raw, prefix) {
return strings.TrimPrefix(raw, prefix)
}
}
return raw
}

func parseBearer(authHeader string, verifier *bearerVerifier, now time.Time) (tokenClaims, *authError) {
if !strings.HasPrefix(authHeader, "Bearer ") {
return tokenClaims{}, &authError{
Expand All @@ -124,6 +162,7 @@ func parseBearer(authHeader string, verifier *bearerVerifier, now time.Time) (to
}
}
raw := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
raw = stripRelayauthTokenPrefix(raw)
parts := strings.Split(raw, ".")
if len(parts) != 3 {
return tokenClaims{}, &authError{
Expand Down
78 changes: 78 additions & 0 deletions internal/httpapi/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -217,6 +218,83 @@ func TestParseBearerRS256HappyPath(t *testing.T) {
}
}

// Regression coverage for the relayauth token-prefix bug: tokens minted
// via relayauth's `/v1/tokens/path` come back wrapped as
// `relay_pa_<jwt>`. Pre-fix, `parseBearer` would split on `.`, end up
// with `relay_pa_<header_b64>` as `parts[0]`, and fail the JSON
// unmarshal with a 401 "invalid jwt header" — silently breaking every
// caller that authenticated with a relayauth-wrapped token. Pin that
// the strip happens BEFORE the dot-split so the underlying JWT verifies
// just like a bare token.
func TestParseBearerStripsRelayauthTokenPrefixes(t *testing.T) {
t.Parallel()

now := time.Now().UTC()
privateKey := mustRSATestKey(t)

jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(jwksDocument{
Keys: []jwkKey{mustRSATestJWK("kid-1", &privateKey.PublicKey)},
})
}))
defer jwksServer.Close()

rawJWT := mustTestRS256JWT(t, privateKey, "kid-1", map[string]any{
"wks": "ws-rs",
"sub": "RelayfileGoWorker",
"scopes": []string{"fs:read"},
"exp": now.Add(time.Hour).Unix(),
"aud": "relayfile",
})

prefixes := []string{
"relay_pa_",
"relay_ws_",
"relay_id_",
}
for _, prefix := range prefixes {
prefix := prefix // capture
t.Run(strings.TrimSuffix(prefix, "_"), func(t *testing.T) {
claims, authErr := parseBearer("Bearer "+prefix+rawJWT, newBearerVerifier(ServerConfig{
JWKSURL: jwksServer.URL,
JWKSFetchTimeout: time.Second,
}), now)
if authErr != nil {
t.Fatalf("parseBearer with %q prefix returned auth error: %+v", prefix, authErr)
}
if claims.WorkspaceID != "ws-rs" {
t.Fatalf("expected workspace ws-rs, got %q", claims.WorkspaceID)
}
if claims.AgentName != "RelayfileGoWorker" {
t.Fatalf("expected subject RelayfileGoWorker, got %q", claims.AgentName)
}
})
}

// Sanity: a bare JWT (no prefix) still parses correctly so we didn't
// break the direct `/v1/tokens` mint path that doesn't wrap.
claims, authErr := parseBearer("Bearer "+rawJWT, newBearerVerifier(ServerConfig{
JWKSURL: jwksServer.URL,
JWKSFetchTimeout: time.Second,
}), now)
if authErr != nil {
t.Fatalf("parseBearer with bare JWT returned auth error: %+v", authErr)
}
if claims.WorkspaceID != "ws-rs" {
t.Fatalf("expected workspace ws-rs from bare JWT, got %q", claims.WorkspaceID)
}

// And: garbage that happens to start with `relay_pa_` but isn't a JWT
// still rejects cleanly (header decode fails AFTER the strip).
_, authErr = parseBearer("Bearer relay_pa_not.a.jwt", newBearerVerifier(ServerConfig{
JWKSURL: jwksServer.URL,
JWKSFetchTimeout: time.Second,
}), now)
if authErr == nil {
t.Fatal("expected auth error for non-JWT after prefix strip")
}
}

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

Expand Down
Loading