cli: reject claim-free login tokens at validation, not at save · Entire

cli: reject claim-free login tokens at validation, not at save

15b28f5→main·

toothbrush·1mo ago·2 files·+58 added/-37 removed

An opaque or claims-free token could never complete a login — RecordLoginContext keys the context and keychain slot on iss and handle/sub — but it only failed at the save step with a bare parse error. validateReceivedToken now requires parseable claims, iss, and handle-or-sub up front, with errors naming the requirement.

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

Sessions

6c73d489af9eView transcript

Changes

2

337 unmodified lines

// a token from a different issuer than the one we asked, or one whose
// claims are already-expired).
//
// Opaque (non-JWT) tokens pass this check unjudged — only unsigned
// (alg:none) JWTs are rejected here, via tokens.ErrUnsignedJWT. They
// still cannot complete a login: RecordLoginContext is the sole
// persistence path and requires iss/handle claims to key the context,
// so a claims-free token fails there with a parse error. Entire-core
// always issues claim-bearing JWTs; opaque-token-only servers are not
// supported.
// It also enforces what the contexts model needs up front:
// RecordLoginContext — the sole persistence path — keys the context and
// keychain slot on the token's iss and handle/sub claims, so a token
// without parseable claims can never complete a login. Rejecting it here
// names the requirement instead of surfacing a parse error from the save
// step. Entire-core always issues claim-bearing JWTs; opaque-token-only
// servers are not supported.
func validateReceivedToken(rawToken, issuerURL string, now time.Time) error {
    claims, err := tokens.ParseClaims(rawToken)
    if errors.Is(err, tokens.ErrUnsignedJWT) {
        return err //nolint:wrapcheck // sentinel surfaces verbatim for caller's errors.Is
    }
    if err != nil {
        return nil //nolint:nilerr // any parse failure other than alg:none means the token isn't a JWT — not this check's concern (RecordLoginContext gates on claims)
        return fmt.Errorf("login server issued a token without parseable JWT claims (claim-bearing JWTs are required): %w", err)
    }
    if claims.Issuer == "" {
        return errors.New("token has no iss claim; cannot record a login context")
    }
    if claims.Handle == "" && claims.Subject == "" {
        return errors.New("token has no handle or sub claim; cannot record a login context")
    }

// iss check: the token must claim to come from the issuer we sent
}

Mcmd/entire/cli/login.go+15/-13

22 unmodified lines

func TestValidateReceivedToken_OpaqueTokenAccepted(t *testing.T) {
// Opaque and otherwise claim-free tokens are rejected up front with an
// error naming the requirement: RecordLoginContext (the sole persistence
// path) keys the context on iss/handle claims, so they could never
// complete a login anyway.
    func TestValidateReceivedToken_RejectsClaimFreeTokens(t *testing.T) {
            t.Parallel()

if err := validateReceivedToken("opaque-token-string", "https://example.test", time.Now()); err != nil {
            t.Fatalf("validateReceivedToken(opaque) = %v, want nil", err)
        }
        cases := map[string]struct {
            token string
            want  string // substring of the rejection
        }{
            "opaque":             {"opaque-token-string", "parseable JWT claims"},
            "3-seg opaque":       {"aaa.bbb.ccc", "parseable JWT claims"},
            "bad base64 payload": {strings.Join([]string{"eyJhbGciOiJSUzI1NiJ9" /* {"alg":"RS256"} */, "!!!not-base64!!!", "sig"}, "."), "parseable JWT claims"},
        }
        for name, tc := range cases {
            t.Run(name, func(t *testing.T) {
                t.Parallel()
                err := validateReceivedToken(tc.token, "https://example.test", time.Now())
                if err == nil || !strings.Contains(err.Error(), tc.want) {
                    t.Fatalf("validateReceivedToken(%s) = %v, want error containing %q", name, err, tc.want)
                }
            })
        }
    }
}

func TestValidateReceivedToken_DotBearingOpaqueTokenAccepted(t *testing.T) { }

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

// 3-segment opaque token whose segments aren't valid base64url: treated // as just-another-opaque-token here. (It still can't complete a login — // RecordLoginContext needs iss/handle claims — but the rejection must // come from there with a claims error, not from this trust check.) if err := validateReceivedToken("aaa.bbb.ccc", "https://example.test", time.Now()); err != nil { t.Fatalf("validateReceivedToken(3-seg opaque) = %v, want nil", err) } jwt := makeJWT(t, {"alg":"RS256"}, {"handle":"alice"}) err := validateReceivedToken(jwt, "https://example.test", time.Now()) if err == nil || !strings.Contains(err.Error(), "no iss claim") { t.Fatalf("validateReceivedToken(no iss) = %v, want no-iss error", err) } }


func TestValidateReceivedToken_BadBase64PayloadAccepted(t *testing.T) {
}

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

// 3-segment token with a JWT-shaped header but a payload that isn't valid
    // base64url. Same principle: any parse failure other than ErrUnsignedJWT
    // is treated as opaque.
    jwt := strings.Join([]string{
        "eyJhbGciOiJSUzI1NiJ9", // {"alg":"RS256"}
        "!!!not-base64!!!",
        "sig",
    }, ".")
    jwt := makeJWT(t, `{"alg":"RS256"}`, `{"iss":"https://example.test"}`)
    err := validateReceivedToken(jwt, "https://example.test", time.Now())
    if err == nil || !strings.Contains(err.Error(), "no handle or sub claim") {
        t.Fatalf("validateReceivedToken(no handle/sub) = %v, want no-handle error", err)
    }
}

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

jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://example.test","sub":"user-123"}) if err := validateReceivedToken(jwt, "https://example.test", time.Now()); err != nil { t.Fatalf("validateReceivedToken(bad base64 payload) = %v, want nil", err) t.Fatalf("validateReceivedToken(sub only) = %v, want nil", err) } }

10 unmodified lines

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

jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://impostor.test"}) jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://impostor.test","handle":"alice"}) err := validateReceivedToken(jwt, "https://example.test", time.Now()) if err == nil || !strings.Contains(err.Error(), "iss mismatch") { t.Fatalf("validateReceivedToken(iss mismatch) = %v, want iss-mismatch error", err) } }

3 unmodified lines

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

jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://example.test/"}) jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://example.test/","handle":"alice"}) if err := validateReceivedToken(jwt, "https://example.test", time.Now()); err != nil { t.Fatalf("validateReceivedToken(trailing slash) = %v, want nil", err) } }

3 unmodified lines

(t *testing.T)

now := time.Unix(1_700_000_000, 0) jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://example.test","exp":1700000000}) jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://example.test","handle":"alice","exp":1700000000}) err := validateReceivedToken(jwt, "https://example.test", now.Add(time.Minute)) if err == nil || !strings.Contains(err.Error(), "already expired") { t.Fatalf("validateReceivedToken(expired) = %v, want already-expired error", err) }

4 unmodified lines

(t *testing.T)

now := time.Unix(1_700_000_000, 0) jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://example.test","exp":1700009000}) jwt := makeJWT(t, {"alg":"RS256"}, {"iss":"https://example.test","handle":"alice","exp":1700009000}) if err := validateReceivedToken(jwt, "https://example.test", now); err != nil { t.Fatalf("validateReceivedToken(future exp) = %v, want nil", err) } }`