package mynalog import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/http" "time" ) // tokenLifetime is the assumed access-token lifetime when the service does not say. The real one is // about an hour; a short assumption only costs an extra refresh. const tokenLifetime = time.Hour // tokenSlack is subtracted from the expiry so a token is renewed slightly early rather than failing // mid-run and forcing a probe. const tokenSlack = time.Minute // deviceIDLength is how many hex characters of the login digest form the device id. It matches what // the web cabinet issues, and the length is what the service accepts. const deviceIDLength = 21 // Session is one authenticated cabinet session. Token is the short-lived bearer credential; // RefreshToken is the long-lived one that renews it and is the only part worth persisting. INN // identifies the taxpayer and is needed to build a receipt link. type Session struct { Token string RefreshToken string INN string ExpiresAt time.Time } // Valid reports whether the session still has a usable access token at now. func (s Session) Valid(now time.Time) bool { return s.Token != "" && now.Before(s.ExpiresAt) } // DeviceIDForLogin derives a stable device identifier from the taxpayer's login. It is // deterministic so that re-authenticating after a restart presents the same "device" instead of // looking like a new one every time. func DeviceIDForLogin(login string) string { sum := sha256.Sum256([]byte(login)) return hex.EncodeToString(sum[:])[:deviceIDLength] } // authResponse is the shape both authentication endpoints answer with. type authResponse struct { Token string `json:"token"` RefreshToken string `json:"refreshToken"` TokenExpireIn string `json:"tokenExpireIn"` Profile struct { INN string `json:"inn"` } `json:"profile"` } // session converts an authentication answer into a Session, defaulting the expiry when the service // did not state one and falling back to the login for the taxpayer id. func (a authResponse) session(login string, now time.Time) (Session, error) { if a.Token == "" { return Session{}, fmt.Errorf("mynalog: authentication returned no token: %w", ErrPermanent) } expires := now.Add(tokenLifetime) if a.TokenExpireIn != "" { if t, err := time.Parse(time.RFC3339, a.TokenExpireIn); err == nil { expires = t } } inn := a.Profile.INN if inn == "" { inn = login } return Session{ Token: a.Token, RefreshToken: a.RefreshToken, INN: inn, ExpiresAt: expires.Add(-tokenSlack), }, nil } // AuthByPassword exchanges the taxpayer's cabinet login (their INN) and password for a session. // Callers persist only the returned RefreshToken: the password is never stored, so this runs once, // interactively, and the session renews itself from then on. func (c *Client) AuthByPassword(ctx context.Context, login, password string, now time.Time) (Session, error) { body := struct { Username string `json:"username"` Password string `json:"password"` DeviceInfo deviceInfo `json:"deviceInfo"` }{Username: login, Password: password, DeviceInfo: c.device()} var out authResponse if err := c.do(ctx, http.MethodPost, "/api/v1/auth/lkfl", "", body, &out); err != nil { // A password login rejected with 401 is bad credentials, not an aged-out token: reporting it // as ErrAuthExpired would send the caller into a pointless refresh loop. if errors.Is(err, ErrAuthExpired) { return Session{}, fmt.Errorf("mynalog: login rejected: %w", ErrPermanent) } return Session{}, err } return out.session(login, now) } // AuthByRefresh renews a session from a stored refresh token. The service sometimes rotates the // token, so the caller must persist the returned RefreshToken whenever it differs from the one // supplied — otherwise the next renewal presents a token the service has already retired. func (c *Client) AuthByRefresh(ctx context.Context, refreshToken, login string, now time.Time) (Session, error) { body := struct { DeviceInfo deviceInfo `json:"deviceInfo"` RefreshToken string `json:"refreshToken"` }{DeviceInfo: c.device(), RefreshToken: refreshToken} var out authResponse if err := c.do(ctx, http.MethodPost, "/api/v1/auth/token", "", body, &out); err != nil { // The stored token is dead; only a fresh password login can recover, so this is permanent // for the caller rather than something to retry. if errors.Is(err, ErrAuthExpired) { return Session{}, fmt.Errorf("mynalog: refresh token rejected: %w", ErrPermanent) } return Session{}, err } sess, err := out.session(login, now) if err != nil { return Session{}, err } if sess.RefreshToken == "" { sess.RefreshToken = refreshToken } return sess, nil }