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
15 changes: 14 additions & 1 deletion pkg/connector/auth_recovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ func (lc *LineClient) isTokenError(err error) bool {
if line.IsNoUsableE2EEGroupKey(err) || line.IsNoUsableE2EEPublicKey(err) {
return false
}
if lc.isSessionInvalidated() {
return false
}
if line.IsLoggedOut(err) {
return false
}
return line.IsAuthError(err)
}

Expand All @@ -53,6 +59,9 @@ func (lc *LineClient) callLineUsing(ctx context.Context, client *line.Client, ca
return struct{}{}, call(client)
},
})
if lc.isLoggedOut(err) {
lc.markLoggedOutByOtherClient(ctx, err)
}
return client, err
}

Expand All @@ -61,10 +70,14 @@ func callLineResult[T any](lc *LineClient, ctx context.Context, call func(*line.
}

func callLineResultUsing[T any](lc *LineClient, ctx context.Context, client *line.Client, call func(*line.Client) (T, error)) (*line.Client, T, error) {
return callLineWithRecovery(ctx, client, lineCallDeps[T]{
client, res, err := callLineWithRecovery(ctx, client, lineCallDeps[T]{
newClient: func() *line.Client { return lc.newClient() },
recover: lc.recoverToken,
isAuthError: lc.isTokenError,
call: call,
})
if lc.isLoggedOut(err) {
lc.markLoggedOutByOtherClient(ctx, err)
}
return client, res, err
}
31 changes: 30 additions & 1 deletion pkg/connector/auth_recovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (

var (
errAuthRequired = errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":119,"reason":"Access token refresh required"}}`)
errLoggedOut = errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":8,"reason":"V3_TOKEN_CLIENT_LOGGED_OUT"}}`)
errSenderKey = errors.New(`API error 400: {"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":83,"reason":"invalid sender key"}}`)
errNotMember = errors.New(`API error 400: {"code":10051,"data":{"name":"TalkException","code":10,"reason":"not a member"}}`)
errNetwork = errors.New("request failed: dial tcp: i/o timeout")
)
Expand Down Expand Up @@ -187,11 +189,22 @@ func TestCallLineWithRecoveryUsesProvidedClientWithoutRecreating(t *testing.T) {
}
}

func TestLineClientIsTokenErrorExcludesE2EEErrors(t *testing.T) {
func TestLineClientIsTokenErrorExcludesNonRecoverableErrors(t *testing.T) {
lc := &LineClient{}
if !lc.isTokenError(errAuthRequired) {
t.Fatal("expected auth-required error to be classified as token error")
}
if lc.isTokenError(errLoggedOut) {
t.Fatal("logged-out sessions must not trigger token recovery")
}
if lc.isTokenError(errSenderKey) {
t.Fatal("invalid sender key sessions must not trigger token recovery")
}
lc.sessionInvalidated = true
if lc.isTokenError(errAuthRequired) {
t.Fatal("invalidated sessions must not trigger token recovery")
}
lc.sessionInvalidated = false
if lc.isTokenError(line.ErrNoUsableE2EEGroupKey) {
t.Fatal("E2EE group key errors must not trigger token recovery")
}
Expand All @@ -216,6 +229,22 @@ func TestRunTokenRecoverySkipsRecentRecovery(t *testing.T) {
}
}

func TestRunTokenRecoveryRejectsInvalidatedSessionBeforeRecentRecovery(t *testing.T) {
lc := &LineClient{recoverTime: time.Now(), sessionInvalidated: true}
var calls int

err := lc.runTokenRecovery(context.Background(), func(context.Context) error {
calls++
return nil
})
if !errors.Is(err, errLineSessionInvalidated) {
t.Fatalf("err = %v, want errLineSessionInvalidated", err)
}
if calls != 0 {
t.Fatalf("recovery calls = %d, want 0", calls)
}
}

func TestRunTokenRecoverySerializesConcurrentRecovery(t *testing.T) {
var lc LineClient
var calls int32
Expand Down
117 changes: 106 additions & 11 deletions pkg/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package connector

import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
Expand All @@ -17,6 +18,18 @@ import (
"github.com/highesttt/matrix-line-messenger/pkg/line"
)

var errLineSessionInvalidated = errors.New("LINE session invalidated by another client")

var newLineAPIClient = line.NewClient

var loginWithCredentials = func(email, password, certificate string) (*line.LoginResult, error) {
return newLineAPIClient("").Login(email, password, certificate)
}

var getProfileWithToken = func(token string) (*line.Profile, error) {
return newLineAPIClient(token).GetProfile()
}

type LineClient struct {
UserLogin *bridgev2.UserLogin
AccessToken string
Expand All @@ -33,6 +46,10 @@ type LineClient struct {
tokenMu sync.RWMutex
recoverMu sync.Mutex
recoverTime time.Time
// sessionInvalidated is set when LINE forcefully logs out this Chrome-style
// session. It prevents background calls from re-logging in before the user
// clicks Reconnect.
sessionInvalidated bool

// cacheMu protects peerKeys, blockedUsers, contactCache, mediaFlowCache,
// noE2EEGroups, groupMemberCache, generatedGroupNameCache, and knownMemberChatMIDs.
Expand Down Expand Up @@ -90,18 +107,34 @@ func (lc *LineClient) setTokens(accessToken, refreshToken string) (string, strin
lc.tokenMu.Lock()
defer lc.tokenMu.Unlock()
lc.AccessToken = accessToken
if accessToken != "" {
lc.sessionInvalidated = false
}
if refreshToken != "" {
lc.RefreshToken = refreshToken
}
return lc.AccessToken, lc.RefreshToken
}

func (lc *LineClient) invalidateAccessToken() {
lc.tokenMu.Lock()
lc.AccessToken = ""
lc.sessionInvalidated = true
lc.tokenMu.Unlock()
}

func (lc *LineClient) hasAccessToken() bool {
return lc.getAccessToken() != ""
}

func (lc *LineClient) isSessionInvalidated() bool {
lc.tokenMu.RLock()
defer lc.tokenMu.RUnlock()
return lc.sessionInvalidated
}

func (lc *LineClient) newClient() *line.Client {
return line.NewClient(lc.getAccessToken())
return newLineAPIClient(lc.getAccessToken())
}

func (lc *LineClient) avatarFromPicturePath(picturePath string) *bridgev2.Avatar {
Expand Down Expand Up @@ -183,7 +216,7 @@ func (lc *LineClient) refreshAndSave(ctx context.Context) error {
return fmt.Errorf("no refresh token available")
}

client := line.NewClient(accessToken)
client := newLineAPIClient(accessToken)
res, err := client.RefreshAccessToken(refreshToken)
if err != nil {
return fmt.Errorf("failed to refresh token: %w", err)
Expand All @@ -198,6 +231,7 @@ func (lc *LineClient) refreshAndSave(ctx context.Context) error {
meta := lc.UserLogin.Metadata.(*UserLoginMetadata)
meta.AccessToken = accessToken
meta.RefreshToken = refreshToken
meta.SessionInvalidated = false
err = lc.UserLogin.Save(ctx)
if err != nil {
lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save refreshed tokens to DB")
Expand All @@ -216,6 +250,58 @@ func (lc *LineClient) isLoggedOut(err error) bool {
return line.IsLoggedOut(err)
}

func (lc *LineClient) shouldAttemptTokenRecovery(ctx context.Context, err error) bool {
if err == nil {
return false
}
if lc.isSessionInvalidated() {
return false
}
if lc.isLoggedOut(err) {
lc.markLoggedOutByOtherClient(ctx, err)
return false
}
return lc.isRefreshRequired(err) || line.IsUnauthorizedStatus(err)
}

func (lc *LineClient) markLoggedOutByOtherClient(ctx context.Context, err error) {
if lc.UserLogin == nil {
lc.invalidateAccessToken()
line.InvalidateOBSTokenCache()
return
}
if lc.UserLogin.Client != nil && lc.UserLogin.Client != lc {
if lc.UserLogin.Bridge != nil {
lc.UserLogin.Bridge.Log.Debug().Err(err).Msg("Ignoring forced logout from stale LINE client")
}
return
}
lc.invalidateAccessToken()
line.InvalidateOBSTokenCache()
lc.saveSessionInvalidated(ctx)
if lc.UserLogin.Bridge != nil {
lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("LINE session invalidated by another client; marking login bad credentials")
}
lc.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: "line-logged-out",
Message: "LINE logged this Chrome Extension session out because another LINE client connected. Click Reconnect in Beeper to reconnect LINE.",
UserAction: status.UserActionRelogin,
})
}

func (lc *LineClient) saveSessionInvalidated(ctx context.Context) {
meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata)
if !ok {
return
}
meta.AccessToken = ""
meta.SessionInvalidated = true
if err := lc.UserLogin.Save(ctx); err != nil && lc.UserLogin.Bridge != nil {
lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Failed to save LINE session invalidation")
}
}

// recoverToken attempts to restore a valid session by refreshing, then re-logging in.
// Returns nil on success. On failure the caller should send StateBadCredentials.
func (lc *LineClient) recoverToken(ctx context.Context) error {
Expand All @@ -233,6 +319,10 @@ func (lc *LineClient) runTokenRecovery(ctx context.Context, recover func(context
lc.recoverMu.Lock()
defer lc.recoverMu.Unlock()

if lc.isSessionInvalidated() {
return errLineSessionInvalidated
}

if !lc.recoverTime.IsZero() && time.Since(lc.recoverTime) < recentTokenRecoveryWindow {
return nil
}
Expand Down Expand Up @@ -274,6 +364,10 @@ func (lc *LineClient) Connect(ctx context.Context) {
}
}
if !lc.hasAccessToken() {
if lc.isSessionInvalidated() {
lc.markLoggedOutByOtherClient(ctx, errLineSessionInvalidated)
return
}
if err := lc.tryLogin(ctx); err != nil {
lc.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Expand All @@ -287,6 +381,10 @@ func (lc *LineClient) Connect(ctx context.Context) {

// Verify the token is still valid before proceeding
if err := lc.ensureValidToken(ctx); err != nil {
if lc.isLoggedOut(err) {
lc.markLoggedOutByOtherClient(ctx, err)
return
}
lc.UserLogin.BridgeState.Send(status.BridgeState{
StateEvent: status.StateBadCredentials,
Error: "line-token-expired",
Expand Down Expand Up @@ -377,8 +475,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error {
Str("email", email).
Bool("has_certificate", certificate != "").
Msg("Attempting to login with email/password...")
client := line.NewClient("")
res, err := client.Login(email, password, certificate)
res, err := loginWithCredentials(email, password, certificate)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
Expand Down Expand Up @@ -406,7 +503,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error {
}

lc.UserLogin.Bridge.Log.Info().Msg("Waiting for PIN verification on mobile device...")
waitClient := line.NewClient("")
waitClient := newLineAPIClient("")
waitRes, err := waitClient.WaitForLogin(res.Verifier, res.NoE2EE)
if err != nil {
return fmt.Errorf("PIN verification failed: %w", err)
Expand All @@ -416,9 +513,8 @@ func (lc *LineClient) tryLogin(ctx context.Context) error {
}
// Replace res with the verified result
res = waitRes
client = waitClient
}
accessToken := client.AccessToken
accessToken := res.AuthToken
refreshToken := ""
if res.TokenV3IssueResult != nil {
if res.TokenV3IssueResult.AccessToken != "" {
Expand All @@ -445,6 +541,7 @@ func (lc *LineClient) tryLogin(ctx context.Context) error {
if meta, ok := lc.UserLogin.Metadata.(*UserLoginMetadata); ok {
meta.AccessToken = accessToken
meta.RefreshToken = refreshToken
meta.SessionInvalidated = false
if res.Certificate != "" {
meta.Certificate = res.Certificate
}
Expand All @@ -458,15 +555,13 @@ func (lc *LineClient) tryLogin(ctx context.Context) error {
}

func (lc *LineClient) ensureValidToken(ctx context.Context) error {
client := lc.newClient()
_, err := client.GetProfile()
_, err := getProfileWithToken(lc.getAccessToken())
if err == nil {
return nil
}

if lc.isLoggedOut(err) {
lc.UserLogin.Bridge.Log.Warn().Err(err).Msg("Session invalidated (logged out by another client), attempting recovery...")
return lc.recoverToken(ctx)
return err
}

if !lc.isRefreshRequired(err) {
Expand Down
Loading
Loading