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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## v2.2.0

通話(カンファレンスコール)関連のモデルとエンドポイントを追加しました
(Go / TypeScript / Python / Rust 共通)。

- `ConferenceCall` / `RealmConferenceCall` モデルと `call_type`(`vdo` /
`voice`)・`joinable_by`(参加可能範囲)enum を追加
- 通話・チャット・投稿の各サービスに関連エンドポイントを追加

また、メールログインが 401(unauthorized)で拒否された場合に、代替の
ログインエンドポイントへ自動でフォールバックするようになりました。

- 一部のアカウントは代替エンドポイントでのみ受け付けられます
- フォールバックは純粋な unauthorized のときだけ発火し、2FA が必要な
ケースや、呼び出し側が明示的に API キーを指定した場合、フォールバック用
キーを空にした場合は発火しません
- 詳細な契約は `PORTING.md` の 6.2 節を参照

## v2.1.0

通話・グループ・投稿まわりのエンドポイントを追加しました(Go / TypeScript / Python 共通)。
Expand Down
36 changes: 36 additions & 0 deletions PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,42 @@ Other invariants:
> not the raw one.) On re-issue, also refresh per-request headers that
> go stale — at minimum `X-Timestamp`.

### 6.2 Alternate login fallback

`loginWithEmail` makes a second login attempt when the primary email
login is rejected with a plain `Unauthorized` (error code 401): it
retries once against `POST /v2/users/login_with_email` with a separate
`webApiKey`. Some accounts are accepted only on this alternate endpoint.
Reference: Go `auth.go` (`loginWithEmailWeb`); mirrored in every port.

The `webApiKey` is an embedded wire constant like `apiKey` (§3.1), with a
per-language default and an override (`WithWebAPIKey` / `webApiKey:` /
`web_api_key=` / `.web_api_key()`). Setting it to `""` disables the
fallback.

Invariants every port MUST match:

- Fire the fallback ONLY on error code `Unauthorized`, never on other
401s — in particular `Required2FA` must fall straight through (the
caller supplies a code, not a different key). Gate on the parsed error
code (`codeOf`), NOT on the bare HTTP status.
- Skip the fallback when the caller pinned a specific `apiKey` (i.e. the
resolved key differs from the client's `apiKey`), and when `webApiKey`
is empty (the off switch).
- Issue the fallback through the UNWRAPPED HTTP layer (§6.1): no header
injection, no Bearer, no 401-refresh. Send the browser client's
minimal headers only (Content-Type, User-Agent, `Agent`,
`X-Device-Info`), and put `webApiKey` in the body's `api_key`.
- On a fallback that returns 2xx but fails to decode, surface that result
(raw body intact, as an `APIError`), NOT the primary 401.
- On any other fallback failure (non-2xx / transport error), surface the
original primary error unchanged.
- On success, activate tokens and persist the session exactly as the
primary path does.

Parity scenarios live in each `auth` test suite (fires on 401 / no fire
on 2FA / no fire when key pinned / disabled / 2xx-decode-fail surfaced).

---

## 7. Signing helpers
Expand Down
98 changes: 57 additions & 41 deletions aliases.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 118 additions & 1 deletion auth.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package yaylib

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"github.com/ekkx/yaylib/v2/gen"
Expand Down Expand Up @@ -114,7 +117,40 @@ func (r LoginWithEmailRequest) Execute() (*gen.LoginUserResponse, *http.Response
LoginEmailUserRequest(body).
Execute()
if err != nil {
return resp, httpResp, err
// Fallback: when the primary (app) login is rejected with a plain
// "unauthorized", retry against the browser login endpoint, which
// authenticates with a different key (WebAPIKey). On success the
// web response replaces the failed one; if the fallback also fails,
// the original error (a proper *APIError) is surfaced unchanged.
//
// The fallback is gated on ErrCodeUnauthorized specifically, not on
// a bare 401 status: other rejections are also 401 but must NOT be
// retried — notably ErrCodeRequired2FA (the caller has to supply a
// code, not switch keys). It is also skipped when the caller pinned
// a specific api_key via .ApiKey(), since the fallback would
// otherwise silently authenticate under a different key.
callerPinnedKey := body.GetApiKey() != r.client.APIKey
if r.client.WebAPIKey != "" && !callerPinnedKey && CodeOf(err) == ErrCodeUnauthorized {
if wResp, wHTTP, wErr := r.client.loginWithEmailWeb(r.ctx, body); wErr == nil {
r.client.Logger.Debug("web login fallback ok",
"event", "login_fallback", "outcome", "ok")
resp, httpResp, err = wResp, wHTTP, nil
} else {
r.client.Logger.Debug("web login fallback failed",
"event", "login_fallback", "outcome", "failed")
// A 2xx web response we couldn't decode is a genuine
// login result, not an auth rejection: surface it (with
// its raw body, readable on wHTTP) rather than masking it
// behind the primary 401. Non-2xx / transport errors fall
// through and the primary error is returned unchanged.
if wHTTP != nil && wHTTP.StatusCode >= 200 && wHTTP.StatusCode < 300 {
return nil, wHTTP, wErr
}
}
}
if err != nil {
return resp, httpResp, err
}
}
if perr := r.client.acceptLogin(email, resp); perr != nil {
return resp, httpResp, perr
Expand All @@ -124,6 +160,87 @@ func (r LoginWithEmailRequest) Execute() (*gen.LoginUserResponse, *http.Response

// ---- internal helpers ----

// bareClient returns an *http.Client that sends requests through the base
// RoundTripper WITHOUT the app transport's header injection, Authorization,
// 401 auto-refresh or retry. It inherits the configured client's timeout
// and, when the app transport is in place, its underlying (caller-supplied
// or default) RoundTripper — so a custom proxy / TLS config still applies.
func (c *Client) bareClient() *http.Client {
bare := &http.Client{Timeout: c.httpClient.Timeout}
if t, ok := c.httpClient.Transport.(*Transport); ok {
bare.Transport = t.Base
}
return bare
}

// loginWithEmailWeb is the fallback login path invoked when the primary
// email login returns 401. It posts to /v2/users/login_with_email — the
// endpoint the browser client uses — authenticating with the client's
// WebAPIKey instead of APIKey.
//
// The request is sent through the UNWRAPPED HTTP layer (the base
// RoundTripper, bypassing the app transport) for two reasons: it must
// carry the browser client's minimal header set rather than the app's
// (X-App-Version / X-Connection-* / X-Device-UUID), and it must not
// pick up the transport's Authorization injection or 401 auto-refresh —
// a login must authenticate purely by its body credentials, never by a
// pre-existing session's Bearer. This mirrors the "re-issue with the
// unwrapped client" rule the retry/refresh paths follow (PORTING §6.1).
// A caller-supplied *http.Client / proxy is still honored: the base
// RoundTripper and timeout are inherited from it. Only the login body's
// api_key and the endpoint version differ from the app login.
func (c *Client) loginWithEmailWeb(ctx context.Context, body gen.LoginEmailUserRequest) (*gen.LoginUserResponse, *http.Response, error) {
payload := map[string]string{
"email": body.GetEmail(),
"password": body.GetPassword(),
"api_key": c.WebAPIKey,
"uuid": body.GetUuid(),
}
if code := body.GetTwoFACode(); code != "" {
payload["two_fa_code"] = code
}
buf, err := json.Marshal(payload)
if err != nil {
return nil, nil, err
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.BaseURL+"/v2/users/login_with_email", bytes.NewReader(buf))
if err != nil {
return nil, nil, err
}
req.Header.Set("Content-Type", "application/json;charset=UTF-8")
// Present as the browser client. The web version string is not
// otherwise known to the SDK, so AppVersion stands in for it.
const webUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
req.Header.Set("User-Agent", webUA)
req.Header.Set("Agent", "YayWeb "+c.AppVersion)
req.Header.Set("X-Device-Info", fmt.Sprintf("Yay %s Web (%s)", c.AppVersion, webUA))

httpResp, err := c.bareClient().Do(req)
if err != nil {
return nil, nil, err
}
respBody, err := io.ReadAll(httpResp.Body)
httpResp.Body.Close()
if err != nil {
return nil, httpResp, err
}
// Restore a readable body on the response so callers / middleware that
// inspect the returned *http.Response see the same bytes we decoded,
// matching the primary login path's semantics.
httpResp.Body = io.NopCloser(bytes.NewReader(respBody))
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
return nil, httpResp, fmt.Errorf("yaylib: web login failed: status %d", httpResp.StatusCode)
}
var out gen.LoginUserResponse
if err := json.Unmarshal(respBody, &out); err != nil {
return nil, httpResp, fmt.Errorf("yaylib: web login decode: %w", err)
}
return &out, httpResp, nil
}

// fillEmailLogin populates APIKey and Uuid from the client if they weren't
// set by the caller, and returns the completed body.
func (c *Client) fillEmailLogin(body gen.LoginEmailUserRequest) gen.LoginEmailUserRequest {
Expand Down
Loading
Loading