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
f266c20
feat(auth): collapse and restyle `entire auth status`
gtrrz-victor Sep 17, 2026
97f6a97
fix(auth): keep absent and zero distinct in the status JSON
gtrrz-victor Sep 17, 2026
7e08cc8
refactor(auth): drop the foreign-region note from the status block
gtrrz-victor Sep 17, 2026
8816dc0
fix(auth): address review findings on the status redesign
gtrrz-victor Sep 17, 2026
cdb402f
test(auth): assert rows by label, not by substring
gtrrz-victor Sep 17, 2026
48fc449
fix(auth): count the sole session when it is not provably yours
gtrrz-victor Sep 17, 2026
545a863
feat(auth): name a login that was ended elsewhere
gtrrz-victor Sep 17, 2026
9bee494
fix(auth): withhold the logout hint from a revoked login
gtrrz-victor Sep 17, 2026
2100e62
Merge origin/main into refactor-entire-status
gtrrz-victor Sep 21, 2026
66f0184
Merge branch 'main' into refactor-entire-status
gtrrz-victor Sep 21, 2026
0a9a96a
refactor(auth): drop the context row when there is only one login
gtrrz-victor Sep 21, 2026
970c45e
Merge origin/main into refactor-entire-status
gtrrz-victor Sep 23, 2026
87e8add
fix(auth): address the trail's open findings on `auth status`
gtrrz-victor Sep 23, 2026
7111696
fix(auth): apply the tense rule to the EXPIRES column too
gtrrz-victor Sep 23, 2026
3b075da
fix(auth): stop claiming a token source when there is no token
gtrrz-victor Sep 23, 2026
12f8e3e
refactor(auth): collapse the claim decoder's two error paths into one
gtrrz-victor Sep 23, 2026
870335b
fix(auth): hold the --json contract on every exit, not just the late …
gtrrz-victor Sep 23, 2026
2d6240f
docs(auth): correct what the SilenceUsage line actually buys
gtrrz-victor Sep 24, 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
764 changes: 670 additions & 94 deletions cmd/entire/cli/auth.go

Large diffs are not rendered by default.

17 changes: 6 additions & 11 deletions cmd/entire/cli/auth/cell_data_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package auth

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -646,20 +645,16 @@ func requireSafeExchangeURL(label, raw string) error {
// Returns "" (no error) when the claim is absent so each caller can phrase
// its own missing-claim error. Shared with git-remote-entire's jurisdiction
// git auth.
//
// Unverified is not unchecked: decodeLoginJWTClaims refuses a token that is not
// a well-formed JWT naming a real algorithm, so an alg:none token errors here
// rather than routing. Every login token a core mints is signed.
func HomeJurisdictionFromLoginJWT(loginJWT string) (string, error) {
parts := strings.Split(loginJWT, ".")
if len(parts) < 2 {
return "", errors.New("login token is not a JWT")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("decode login token payload: %w", err)
}
var claims struct {
HomeJurisdiction string `json:"home_jurisdiction"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", fmt.Errorf("parse login token payload: %w", err)
if err := decodeLoginJWTClaims(loginJWT, &claims); err != nil {
return "", err
}
return NormalizeJurisdiction(claims.HomeJurisdiction)
}
Expand Down
78 changes: 78 additions & 0 deletions cmd/entire/cli/auth/session_family.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package auth

import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"

"github.com/entireio/auth-go/tokens"
)

// LoginTokenExpiry reports when a login JWT's access half stops being accepted,
// without verifying the signature — the server re-verifies, and a caller only
// uses this to say how long the bearer in hand has left.
//
// This is NOT a session's lifetime. A session is a refresh-token family lasting
// weeks; the access token it mints lasts about an hour and is normally renewed
// long before it lapses. The two coincide in exactly one case worth reporting:
// a family revoked while its last access token is still inside that hour, where
// the token's expiry IS when the user gets logged out.
//
// Returns a zero time (no error) when the token carries no exp, so a caller can
// stay quiet rather than invent a deadline.
func LoginTokenExpiry(loginJWT string) (time.Time, error) {
claims, err := tokens.ParseClaims(loginJWT)
if err != nil {
return time.Time{}, err //nolint:wrapcheck // ParseClaims already names the token
}
return claims.ExpiresAt, nil
}

// decodeLoginJWTClaims reads custom claims out of a login JWT's payload into
// out, without verifying the signature — the server re-verifies, and the
// readers here only use what they find to recognise a row or route a request.
//
// Unverified is not unchecked. tokens.ParseClaims runs first, so the token must
// be a well-formed three-segment JWT naming a real algorithm; an alg:none token
// is refused here exactly as CoreURLFromEnvToken refuses one, keeping every
// reader in this package on a single policy.
//
// It then reads the payload again, because ParseClaims keeps no raw copy and
// has no field for the claims wanted here. Decoding and unmarshalling share one
// error path: the segment is known to decode once ParseClaims has passed, while
// a claim of the wrong JSON type still fails here (ParseClaims type-checks only
// its own fields), and splitting the two would put a reachable failure beside
// an unreachable one as though they were equals.
func decodeLoginJWTClaims(loginJWT string, out any) error {
if _, err := tokens.ParseClaims(loginJWT); err != nil {
return fmt.Errorf("login token: %w", err)
}
payload, err := base64.RawURLEncoding.DecodeString(strings.Split(loginJWT, ".")[1])
if err == nil {
err = json.Unmarshal(payload, out)
}
if err != nil {
return fmt.Errorf("read login token claims: %w", err)
}
return nil
}

// SessionFamilyIDFromLoginJWT reads the fid (refresh-token family id) claim
// without verifying the signature — the caller only uses it to recognise which
// row of a session listing is its own, exactly as HomeJurisdictionFromLoginJWT
// only routes. A login session IS a refresh-token family (see
// api.AuthSession), so fid is what identifies the caller's session.
//
// Returns "" (no error) when the claim is absent, so a core too old to mint it
// degrades to "no session identified" rather than to an error.
func SessionFamilyIDFromLoginJWT(loginJWT string) (string, error) {
var claims struct {
FID string `json:"fid"`
}
if err := decodeLoginJWTClaims(loginJWT, &claims); err != nil {
return "", err
}
return claims.FID, nil
}
92 changes: 92 additions & 0 deletions cmd/entire/cli/auth/session_family_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package auth

import (
"encoding/base64"
"encoding/json"
"errors"
"testing"

"github.com/entireio/auth-go/tokens"
)

// signedShapeJWT builds a token with the given payload claims and a real
// algorithm. The signature itself is never verified here — the claim readers
// only pick a row out of a listing the server already authorized — but the
// header must name an algorithm, which is what separates a token worth reading
// from an alg:none one anybody can mint.
func signedShapeJWT(t *testing.T, claims map[string]any) string {
t.Helper()
payload, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
enc := base64.RawURLEncoding.EncodeToString
return enc([]byte(`{"alg":"HS256","typ":"JWT"}`)) + "." + enc(payload) + "." + enc([]byte("sig"))
}

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

t.Run("reads the fid claim", func(t *testing.T) {
t.Parallel()
got, err := SessionFamilyIDFromLoginJWT(signedShapeJWT(t, map[string]any{"fid": "fam-123", "sub": "u1"}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "fam-123" {
t.Errorf("fid = %q, want %q", got, "fam-123")
}
})

// A core too old to mint fid must degrade to "no session identified", not
// to an error that would take the whole status command down with it.
t.Run("absent claim is empty, not an error", func(t *testing.T) {
t.Parallel()
got, err := SessionFamilyIDFromLoginJWT(signedShapeJWT(t, map[string]any{"sub": "u1"}))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "" {
t.Errorf("fid = %q, want empty", got)
}
})

// A claim of the wrong JSON type is the failure that reaches this decoder
// rather than ParseClaims, which type-checks only its own fields.
t.Run("wrong-typed claim is an error", func(t *testing.T) {
t.Parallel()
if _, err := SessionFamilyIDFromLoginJWT(signedShapeJWT(t, map[string]any{"fid": 123})); err == nil {
t.Error("err = nil, want a numeric fid refused rather than read as empty")
}
})

for name, tok := range map[string]string{
"not a JWT": "opaque-token",
"undecodable payload": "aGVhZGVy.!!!not-base64!!!.sig",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
if _, err := SessionFamilyIDFromLoginJWT(tok); err == nil {
t.Errorf("SessionFamilyIDFromLoginJWT(%q) = nil error, want one", tok)
}
})
}
}

// Every claim reader in this package refuses an unsigned token, so a caller
// cannot mint one to name itself a session or a home region. The check belongs
// to decodeLoginJWTClaims, so both readers are asserted against it.
func TestDecodeLoginJWTClaims_RejectsAlgNone(t *testing.T) {
t.Parallel()

enc := base64.RawURLEncoding.EncodeToString
algNone := enc([]byte(`{"alg":"none"}`)) + "." +
enc([]byte(`{"fid":"fam-123","home_jurisdiction":"us"}`)) + "."

if _, err := SessionFamilyIDFromLoginJWT(algNone); !errors.Is(err, tokens.ErrUnsignedJWT) {
t.Errorf("SessionFamilyIDFromLoginJWT err = %v, want ErrUnsignedJWT", err)
}
if _, err := HomeJurisdictionFromLoginJWT(algNone); !errors.Is(err, tokens.ErrUnsignedJWT) {
t.Errorf("HomeJurisdictionFromLoginJWT err = %v, want ErrUnsignedJWT", err)
}
}
Loading
Loading