Skip to content

Commit 36a6339

Browse files
committed
feat(auth): implement stateful OTP login flow
Refactor `telkomsel.Auth.Login` into `RequestOTP` and `SubmitOTP` methods. Introduce `StateAwaitingOTP` and `StateLoggingIn` to `model.SessionState`. Add `PendingAuthId` and `PendingAmlbCookie` to `model.Session` to store intermediate authentication data. Update `mcp/server.go` `login` and `submit_otp` tools to use the new stateful methods. Remove global `otpChan` and `otpMu` from `mcp/server.go`. This provides a more robust and explicit state management for the OTP login process. It decouples OTP request and submission, simplifying `mcp/server.go` logic and improving error handling.
1 parent 657deab commit 36a6339

3 files changed

Lines changed: 85 additions & 79 deletions

File tree

mcp/server.go

Lines changed: 21 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ import (
2020
)
2121

2222
var (
23-
otpChan chan string
24-
otpMu sync.Mutex
2523
autoCancelMu sync.Mutex
2624
autoCancel context.CancelFunc
2725
)
@@ -63,45 +61,21 @@ func Run() {
6361
return mcp.NewToolResultError(fmt.Sprintf("Invalid phone number: %v", err)), nil
6462
}
6563

66-
otpMu.Lock()
67-
otpChan = make(chan string, 1)
68-
otpMu.Unlock()
69-
70-
errChan := make(chan error, 1)
71-
72-
go func() {
73-
auth := telkomsel.NewAuth()
74-
loginCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
75-
defer cancel()
76-
77-
session, loginErr := auth.Login(loginCtx, local, func() (string, error) {
78-
otpMu.Lock()
79-
ch := otpChan
80-
otpMu.Unlock()
81-
82-
select {
83-
case otp := <-ch:
84-
return otp, nil
85-
case <-loginCtx.Done():
86-
return "", fmt.Errorf("OTP timeout")
87-
}
88-
})
89-
90-
if loginErr != nil {
91-
errChan <- loginErr
92-
return
93-
}
94-
95-
sessions.Set(mcpUserID, session)
96-
log.Printf("[MCP] Login success for +%s", full)
97-
}()
64+
session := sessions.Get(mcpUserID)
65+
if session == nil {
66+
session = &model.Session{}
67+
}
68+
session.Phone = local
69+
session.FullPhone = "62" + local
70+
session.State = model.StateLoggingIn
9871

99-
select {
100-
case err := <-errChan:
72+
auth := telkomsel.NewAuth()
73+
if err := auth.RequestOTP(ctx, session); err != nil {
10174
return mcp.NewToolResultError(fmt.Sprintf("Login failed: %v", err)), nil
102-
case <-time.After(15 * time.Second):
103-
return mcp.NewToolResultText(fmt.Sprintf("📲 OTP dikirim ke +%s. Gunakan tool `submit_otp` dengan kode OTP untuk menyelesaikan login.", full)), nil
10475
}
76+
77+
sessions.Set(mcpUserID, session)
78+
return mcp.NewToolResultText(fmt.Sprintf("📲 OTP dikirim ke +%s. Gunakan tool `submit_otp` dengan kode OTP untuk menyelesaikan login.", full)), nil
10579
},
10680
)
10781

@@ -116,20 +90,18 @@ func Run() {
11690
return mcp.NewToolResultError("otp argument is required"), nil
11791
}
11892

119-
otpMu.Lock()
120-
ch := otpChan
121-
otpMu.Unlock()
122-
123-
if ch == nil {
124-
return mcp.NewToolResultError("No login in progress. Call 'login' first."), nil
93+
session := sessions.Get(mcpUserID)
94+
if session == nil || session.State != model.StateAwaitingOTP {
95+
return mcp.NewToolResultError("No login in progress or session expired. Call 'login' first."), nil
12596
}
12697

127-
select {
128-
case ch <- otp:
129-
return mcp.NewToolResultText("✓ OTP dikirim, memproses login... Cek profil dengan `get_profile` untuk verifikasi."), nil
130-
default:
131-
return mcp.NewToolResultError("OTP channel full or login already completed."), nil
98+
auth := telkomsel.NewAuth()
99+
if err := auth.SubmitOTP(ctx, session, otp); err != nil {
100+
return mcp.NewToolResultError(fmt.Sprintf("Submit OTP failed: %v", err)), nil
132101
}
102+
103+
sessions.Set(mcpUserID, session)
104+
return mcp.NewToolResultText("✅ Login berhasil! Token disimpan dengan aman. Cek profil dengan `get_profile` untuk verifikasi."), nil
133105
},
134106
)
135107

model/session.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,14 @@ const (
1818
StateAwaitingAutoInt SessionState = "awaiting_auto_interval"
1919
StateAwaitingAutoThreshold SessionState = "awaiting_auto_threshold"
2020
StateAwaitingAutoOffer SessionState = "awaiting_auto_offer_id"
21+
StateAwaitingOTP SessionState = "awaiting_otp"
2122
StateLoggedIn SessionState = "logged_in"
2223
StateLoggingIn SessionState = "logging_in"
2324
)
2425

2526
func (s SessionState) IsAwaiting() bool {
2627
switch s {
27-
case StateAwaitingPhone, StateAwaitingOfferID, StateAwaitingAutoInt, StateAwaitingAutoThreshold, StateAwaitingAutoOffer:
28+
case StateAwaitingPhone, StateAwaitingOfferID, StateAwaitingAutoInt, StateAwaitingAutoThreshold, StateAwaitingAutoOffer, StateAwaitingOTP:
2829
return true
2930
}
3031
return false
@@ -41,8 +42,10 @@ type Session struct {
4142
State SessionState `json:"state"`
4243
LastLoginAt time.Time `json:"last_login_at"`
4344

44-
PendingOfferID string `json:"pending_offer_id,omitempty"`
45-
PendingPayment string `json:"pending_payment,omitempty"`
45+
PendingAuthId string `json:"pending_auth_id,omitempty"`
46+
PendingAmlbCookie string `json:"pending_amlbcookie,omitempty"`
47+
PendingOfferID string `json:"pending_offer_id,omitempty"`
48+
PendingPayment string `json:"pending_payment,omitempty"`
4649

4750
AutoBuyInterval int `json:"auto_buy_interval"`
4851
AutoBuyThreshold int `json:"auto_buy_threshold"`

telkomsel/auth.go

Lines changed: 58 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,34 @@ func NewAuth() *Auth {
2929
}
3030

3131
func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCallback) (*model.Session, error) {
32-
a.mu.Lock()
33-
defer a.mu.Unlock()
34-
3532
fullPhone := "62" + localPhone
36-
3733
session := &model.Session{
3834
Phone: localPhone,
3935
FullPhone: fullPhone,
4036
State: model.StateLoggingIn,
4137
}
4238

39+
if err := a.RequestOTP(ctx, session); err != nil {
40+
return nil, err
41+
}
42+
43+
log.Println("[Login] Waiting for OTP from user...")
44+
otp, err := otpCallback()
45+
if err != nil {
46+
return nil, fmt.Errorf("OTP callback: %w", err)
47+
}
48+
49+
if err := a.SubmitOTP(ctx, session, otp); err != nil {
50+
return nil, err
51+
}
52+
53+
return session, nil
54+
}
55+
56+
func (a *Auth) RequestOTP(ctx context.Context, session *model.Session) error {
57+
a.mu.Lock()
58+
defer a.mu.Unlock()
59+
4360
c := &http.Client{
4461
CheckRedirect: func(req *http.Request, via []*http.Request) error {
4562
return http.ErrUseLastResponse
@@ -61,7 +78,7 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
6178
"Sec-Fetch-Site": []string{"same-site"},
6279
"Sec-Fetch-Mode": []string{"cors"},
6380
"Sec-Fetch-Dest": []string{"empty"},
64-
"Am-Phonenumber": []string{"+" + fullPhone},
81+
"Am-Phonenumber": []string{"+" + session.FullPhone},
6582
"Am-Clientid": []string{clientID},
6683
"Am-Send": []string{"otp"},
6784
"Content-Type": []string{"application/json"},
@@ -77,21 +94,21 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
7794
req1.Header = headers1
7895
resp1, err := c.Do(req1)
7996
if err != nil {
80-
return nil, fmt.Errorf("request OTP post: %w", err)
97+
return fmt.Errorf("request OTP post: %w", err)
8198
}
8299
defer resp1.Body.Close()
83100

84101
if resp1.StatusCode != 200 {
85102
b, _ := io.ReadAll(resp1.Body)
86-
return nil, fmt.Errorf("request OTP status %d: %s", resp1.StatusCode, string(b))
103+
return fmt.Errorf("request OTP status %d: %s", resp1.StatusCode, string(b))
87104
}
88105

89106
var authResp1 struct {
90107
AuthId string `json:"authId"`
91108
Callbacks []any `json:"callbacks"`
92109
}
93110
if err := json.NewDecoder(resp1.Body).Decode(&authResp1); err != nil {
94-
return nil, fmt.Errorf("decode OTP response: %w", err)
111+
return fmt.Errorf("decode OTP response: %w", err)
95112
}
96113

97114
amlbcookie := ""
@@ -101,15 +118,27 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
101118
}
102119
}
103120

104-
log.Println("[Login] Waiting for OTP from user...")
105-
otp, err := otpCallback()
106-
if err != nil {
107-
return nil, fmt.Errorf("OTP callback: %w", err)
121+
session.PendingAuthId = authResp1.AuthId
122+
session.PendingAmlbCookie = amlbcookie
123+
session.State = model.StateAwaitingOTP
124+
return nil
125+
}
126+
127+
func (a *Auth) SubmitOTP(ctx context.Context, session *model.Session, otp string) error {
128+
a.mu.Lock()
129+
defer a.mu.Unlock()
130+
131+
c := &http.Client{
132+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
133+
return http.ErrUseLastResponse
134+
},
108135
}
136+
userAgent := []string{config.AuthUserAgent}
137+
authURL := fmt.Sprintf("%s/iam/v1/realms/%s/authenticate?authIndexType=service&authIndexValue=phoneLogin", config.CiamBaseURL, config.CiamRealm)
109138

110139
log.Println("[Login] Submitting OTP...")
111140
reqBody2Map := map[string]interface{}{
112-
"authId": authResp1.AuthId,
141+
"authId": session.PendingAuthId,
113142
"callbacks": []map[string]interface{}{
114143
{
115144
"type": "PasswordCallback",
@@ -148,28 +177,28 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
148177
"Accept-Language": []string{"id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7"},
149178
"Priority": []string{"u=1, i"},
150179
}
151-
if amlbcookie != "" {
152-
headers2.Set("Cookie", amlbcookie)
180+
if session.PendingAmlbCookie != "" {
181+
headers2.Set("Cookie", session.PendingAmlbCookie)
153182
}
154183

155184
req2, _ := http.NewRequestWithContext(ctx, "POST", authURL, bytes.NewReader(reqBody2Bytes))
156185
req2.Header = headers2
157186
resp2, err := c.Do(req2)
158187
if err != nil {
159-
return nil, fmt.Errorf("submit OTP post: %w", err)
188+
return fmt.Errorf("submit OTP post: %w", err)
160189
}
161190
defer resp2.Body.Close()
162191

163192
if resp2.StatusCode != 200 {
164193
b, _ := io.ReadAll(resp2.Body)
165-
return nil, fmt.Errorf("submit OTP status %d: %s", resp2.StatusCode, string(b))
194+
return fmt.Errorf("submit OTP status %d: %s", resp2.StatusCode, string(b))
166195
}
167196

168197
var authResp2 struct {
169198
TokenId string `json:"tokenId"`
170199
}
171200
if err := json.NewDecoder(resp2.Body).Decode(&authResp2); err != nil {
172-
return nil, fmt.Errorf("decode submit OTP response: %w", err)
201+
return fmt.Errorf("decode submit OTP response: %w", err)
173202
}
174203

175204
iPlanetCookie := ""
@@ -206,8 +235,8 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
206235
"Sec-Fetch-Dest": []string{"empty"},
207236
}
208237
cookies3 := []string{}
209-
if amlbcookie != "" {
210-
cookies3 = append(cookies3, amlbcookie)
238+
if session.PendingAmlbCookie != "" {
239+
cookies3 = append(cookies3, session.PendingAmlbCookie)
211240
}
212241
if iPlanetCookie != "" {
213242
cookies3 = append(cookies3, iPlanetCookie)
@@ -220,7 +249,7 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
220249
req3.Header = headers3
221250
resp3, err := c.Do(req3)
222251
if err != nil {
223-
return nil, fmt.Errorf("authorize get: %w", err)
252+
return fmt.Errorf("authorize get: %w", err)
224253
}
225254
defer resp3.Body.Close()
226255

@@ -233,7 +262,7 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
233262
}
234263
}
235264
if code == "" {
236-
return nil, fmt.Errorf("could not extract code from authorize redirect. Location: %s", location)
265+
return fmt.Errorf("could not extract code from authorize redirect. Location: %s", location)
237266
}
238267

239268
log.Println("[Login] Requesting access token...")
@@ -269,25 +298,25 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
269298
req4.Header = headers4
270299
resp4, err := c.Do(req4)
271300
if err != nil {
272-
return nil, fmt.Errorf("access token post: %w", err)
301+
return fmt.Errorf("access token post: %w", err)
273302
}
274303
defer resp4.Body.Close()
275304

276305
if resp4.StatusCode != 200 {
277306
b, _ := io.ReadAll(resp4.Body)
278-
return nil, fmt.Errorf("access token status %d: %s", resp4.StatusCode, string(b))
307+
return fmt.Errorf("access token status %d: %s", resp4.StatusCode, string(b))
279308
}
280309

281310
var tokenResp struct {
282311
AccessToken string `json:"access_token"`
283312
IdToken string `json:"id_token"`
284313
}
285314
if err := json.NewDecoder(resp4.Body).Decode(&tokenResp); err != nil {
286-
return nil, fmt.Errorf("decode access token response: %w", err)
315+
return fmt.Errorf("decode access token response: %w", err)
287316
}
288317

289318
if tokenResp.AccessToken == "" {
290-
return nil, fmt.Errorf("access token is empty")
319+
return fmt.Errorf("access token is empty")
291320
}
292321

293322
accessAuthEnc, authEnc := GenerateAuthHeaders(tokenResp.AccessToken, tokenResp.IdToken)
@@ -298,8 +327,10 @@ func (a *Auth) Login(ctx context.Context, localPhone string, otpCallback OTPCall
298327
session.Hash = util.RandomHex(28)
299328
session.WebAppVersion = config.WebAppVersion
300329

330+
session.PendingAuthId = ""
331+
session.PendingAmlbCookie = ""
301332
session.State = model.StateLoggedIn
302333
session.LastLoginAt = time.Now()
303334
log.Println("[Login] ✓ Login successful, tokens captured!")
304-
return session, nil
335+
return nil
305336
}

0 commit comments

Comments
 (0)