mastodon / mastodon-ios

Official iOS app for Mastodon
https://app.joinmastodon.org/ios
GNU General Public License v3.0
2.08k stars 268 forks source link

[BUG] Missing Authorization header on call to /api/v1/accounts/verify_credentials #967

Open daenney opened 1 year ago

daenney commented 1 year ago

Is there an existing issue for this?

Current Behavior

The iOS app is not able to login to certain instances when account-domain != web-domain. I've debugged this against my own instance at dny.social (which uses ap.dny.social) and took a peak at the traffic using mitmproxy.

This happens because at some point the Masto app gets confused about which domain it should call /api/v1/accounts/verify_credentials on. It calls it on dny.social, which returns a 307 to ap.dny.social. The client then repeats the call but crucially omits the Authorization header causing the call to fail and it not being able to login.

Expected Behavior

I can login when having successfully completed the auth flow and gotten a valid authorization token.

Steps To Reproduce

  1. Trying to login to main own instance

I'm including the request flow here so you can follow along, with a few things redacted.

We start with:

POST https://dny.social/api/v1/apps HTTP/2.0
accept: */*
content-type: application/json; charset=utf-8

{"redirect_uris":"mastodon:\/\/joinmastodon.org\/oauth","website":"https:\/\/app.joinmastodon.org\/ios","client_name":"Mastodon for iOS","scopes":"read write follow push"}

---

HTTP/2.0 307 
server: nginx
location: https://ap.dny.social/api/v1/apps

Next up:

POST https://ap.dny.social/api/v1/apps HTTP/2.0
accept: */*

{"redirect_uris":"mastodon:\/\/joinmastodon.org\/oauth","website":"https:\/\/app.joinmastodon.org\/ios","client_name":"Mastodon for iOS","scopes":"read write follow push"}

---

HTTP/2.0 200 
content-type: application/json; charset=utf-8

{"id":"REDACTED","name":"Mastodon for iOS","website":"https://app.joinmastodon.org/ios","redirect_uri":"mastodon://joinmastodon.org/oauth","client_id":"REDACTED","client_secret":"REDACTED}

Triggers the auth flow, hits dny.social again so we redirect:

GET https://dny.social/oauth/authorize?response_type=code&client_id=REDACTED&redirect_uri=mastodon://joinmastodon.org/oauth&scope=read%20write%20follow%20push HTTP/2.0

---

HTTP/2.0 307 
location: https://ap.dny.social/oauth/authorize?response_type=code&client_id=REDACTED&redirect_uri=mastodon://joinmastodon.org/oauth&scope=read%20write%20follow%20push

Now tries to trigger the sign-in:

GET https://ap.dny.social/oauth/authorize?response_type=code&client_id=REDACTED&redirect_uri=mastodon://joinmastodon.org/oauth&scope=read%20write%20follow%20push HTTP/2.0

---

HTTP/2.0 303 
location: /auth/sign_in

Loads the signup page:

GET https://ap.dny.social/auth/sign_in HTTP/2.0

---

HTTP/2.0 200 
content-type: text/html; charset=utf-8
content-length: 1735

<!DOCTYPE html>
...

We go through the UI:

POST https://ap.dny.social/auth/sign_in HTTP/2.0

username=<user@domain>&password=<REDACTED>

---

HTTP/2.0 302 
location: /oauth/authorize

Completed the flow, getting returned to the app now:

GET https://ap.dny.social/oauth/authorize HTTP/2.0

---

HTTP/2.0 200 
<!DOCTYPE html>
...
POST https://ap.dny.social/oauth/authorize HTTP/2.0

---

HTTP/2.0 302 
location: mastodon://joinmastodon.org/oauth?code=REDACTED

We go to post on /oauth/token, starting at dny.social:

POST https://dny.social/oauth/token HTTP/2.0
accept: */*

{"client_id":"REDACTED","scope":"read write follow push","redirect_uri":"mastodon:\/\/joinmastodon.org\/oauth","code":"REDACTED","client_secret":"REDACTED","grant_type":"authorization_code"}

---

HTTP/2.0 307 
location: https://ap.dny.social/oauth/token

So it tries again, on ap.dny.social:

POST https://ap.dny.social/oauth/token HTTP/2.0

{"client_id":"REDACTED","scope":"read write follow push","redirect_uri":"mastodon:\/\/joinmastodon.org\/oauth","code":"REDACTED","client_secret":"REDACTED","grant_type":"authorization_code"}

---

HTTP/2.0 200 

{"access_token":"REDACTED","created_at":UNIX_TS,"scope":"read write follow push","token_type":"Bearer"}

Brilliant, it should have the token now. Oauth is quite the dance :dancers:.

Last step, /api/v1/accounts/verify_credentials, starting on dny.social again:

GET https://dny.social/api/v1/accounts/verify_credentials HTTP/2.0
authorization: Bearer REDACTED

---

HTTP/2.0 307 
location: https://ap.dny.social/api/v1/accounts/verify_credentials

Retries the request on ap.dny.social:

GET https://ap.dny.social/api/v1/accounts/verify_credentials HTTP/2.0
accept: */*
user-agent: Mastodon/286 CFNetwork/1404.0.5 Darwin/22.3.0
accept-language: en-GB,en;q=0.9
cache-control: no-cache
accept-encoding: gzip, deflate, br
content-length: 0

---

HTTP/2.0 401 

<!DOCTYPE html>

<main>
    <section class="error">
        <h1>An error occured:</h1>
        <pre>Unauthorized: token not supplied</pre>

        <div>
            <span>Request ID:</span> <code>REDACTED</code>
        </div>

    </section>
</main>

As you can see, in that last request the authorization header is missing, resulting in my instance returning a 401 with a token not supplied error.

Environment

- Device: iPhone 12 mini
- OS: iOS 16.3
- Version: 1.5
- Build: idk where to find this (I don't think I can access the part of the UI without being logged in somewhere first where this is shown)

Anything else?

My instance runs GoToSocial, which tries to pretty faithfully emulate the Mastodon client.

If I tell the Mastodon app to login through ap.dny.social instead directly everything works fine. It works almost fine with dny.social too except for losing the authorization header on that last redirect.

daenney commented 1 year ago

One thing I find very peculiar; the app seems to let me use both the "account domain" and the "instance domain". But instead of relying on redirects being in place, it seems the app could check .well-known/nodeinfo and grabbing the URL from there and using that forwards. That would also avoid all the extra redirected requests.

$ curl -I https://dny.social/.well-known/nodeinfo
HTTP/2 301 
location: https://ap.dny.social/.well-known/nodeinfo
$ curl -L -XGET https://dny.social/.well-known/nodeinfo | jq .
{
  "links": [
    {
      "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
      "href": "https://ap.dny.social/nodeinfo/2.0"
    }
  ]
}
j-f1 commented 1 year ago

Related: https://github.com/mastodon/mastodon-ios/issues/524

j-f1 commented 1 year ago

I started working on an approach to get this to work right but I think while working on that I figured out the underlying reason for the failure. It seems like URLSession (which we use for making network requests) drops the Authorization header when a request is redirected between domains (here, dny.socialap.dny.social). Ultimately I think the workaround here would be to log into ap.dny.social instead, in https://github.com/mastodon/mastodon-ios/issues/524#issuecomment-1322647936 I discussed the challenges of figuring out what the real Mastodon server domain should be. I’ll definitely think about this more to see if there can be a more satisfying solution though!

daenney commented 1 year ago

Right. I think it makes a ton of sense for the header to get dropped, though it's a bit odd that it's happy for other forms of auth to be carried over through redirects without re-prompting the user.

The flow seems to start with a call to https://dny.social/api/v1/apps in my case. Perhaps if the redirect is detected there instead of followed, the user could be prompted to verify that domain and then start the flow from there instead? That should make the flow domain-change free which would solve the header getting lost on the last call.

daenney commented 1 year ago

I just discovered something interesting, the Android client doesn't have this issue. I took a look at what happens with pixie.town based on their admin's suggestion.

When using the iOS client the first call is this:

POST https://pixie.town/api/v1/apps
{
    "client_name": "Mastodon for iOS",
    "redirect_uris": "mastodon://joinmastodon.org/oauth",
    "scopes": "read write follow push",
    "website": "https://app.joinmastodon.org/ios"
}

This returns a 404 and Mastodon iOS gives up.

The Android client however, does this chain:

404 GET /api/v1/instance "MastodonAndroid/1.2.0" (account domain)
301 GET /.well-known/host-meta "okhttp/3.14.9" (account domain, to api domain)
200 GET /.well-known/host-meta "okhttp/3.14.9" (api domain)
200 GET /api/v1/instance "MastodonAndroid/1.2.0" (api domain)

By using the .well-known/host-meta lookup it seems to resolve the account vs api domain and things then work correctly going forwards. If the iOS client implemented the same logic, then any instance implementation providing the .well-known/host-meta endpoint would function.

daenney commented 1 year ago

If the iOS client would treat a 3xx or 4xx on /api/v1/instance as a "discover through host-meta" hint instead then that should solve the domain to start the flow from.