Created
April 10, 2020 20:50
-
-
Save xSke/8a4f06f9499a17b3e28cedfc094f57ca to your computer and use it in GitHub Desktop.
Script for obtaining AC:NH island/profile info by Nintendo account login
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import base64 | |
import datetime | |
import hashlib | |
import json | |
import random | |
import re | |
import requests | |
import secrets | |
import string | |
import sys | |
import uuid | |
import webbrowser | |
nintendo_client_id = "71b963c1b7b6d119" # Hardcoded in app, this is for the NSO app (parental control app has a different ID) | |
redirect_uri_regex = re.compile(r"npf71b963c1b7b6d119:\/\/auth#session_state=([0-9a-f]{64})&session_token_code=([A-Za-z0-9-._]+)&state=([A-Za-z]{50})") | |
browser_agent = "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_3 like Mac OS X) AppleWebKit/603.3.8 (KHTML, like Gecko) Mobile/14G60" | |
def parse_redirect_uri(uri): | |
m = redirect_uri_regex.match(uri) | |
if not m: | |
return None | |
return (m.group(1), m.group(2), m.group(3)) | |
def generate_challenge(): | |
# PKCE challenge/response | |
# Verifier: 32 random bytes, Base64-encoded | |
# Challenge: Those bytes, in hex, hashed with SHA256, Base64-encoded | |
verifier = secrets.token_bytes(32) | |
verifier_b64 = base64.urlsafe_b64encode(verifier).decode().replace("=", "") | |
s256 = hashlib.sha256() | |
s256.update(verifier_b64.encode()) | |
challenge_b64 = base64.urlsafe_b64encode(s256.digest()).decode().replace("=", "") | |
return verifier_b64, challenge_b64 | |
def generate_state(): | |
# OAuth state is just a random opaque string | |
alphabet = string.ascii_letters | |
return "".join(random.choice(alphabet) for _ in range(50)) | |
rsess = requests.Session() | |
def call_flapg(id_token, timestamp, request_id, hash, type): | |
# Calls the flapg API to get an "f-code" for a login request | |
# this is generated by the NSO app but hasn't been reverse-engineered at the moment. | |
flapg_resp = rsess.post("https://flapg.com/ika2/api/login?public", headers={ | |
"X-Token": id_token, | |
"X-Time": timestamp, | |
"X-GUID": request_id, | |
"X-Hash": hash, | |
"X-Ver": "3", | |
"X-IID": type | |
}) | |
if flapg_resp.status_code != 200: | |
print("Error obtaining f-code from flapg API, aborting... ({})".format(flapg_resp.text)) | |
return flapg_resp.json()["result"]["f"], flapg_resp.json()["result"]["p1"] | |
def call_s2s(token, timestamp): | |
# I'm not entirely sure what this API does but it gets you a code that you need to move on. | |
resp = rsess.post("https://elifessler.com/s2s/api/gen2", data={ | |
"naIdToken": token, | |
"timestamp": timestamp | |
}, headers={ | |
"User-Agent": "astrid/0.0.1" # This is just me testing things, replace this with a real user agent in a real-world app | |
}) | |
if resp.status_code != 200: | |
print("Error obtaining auth hash from Eli Fessler's S2S server, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
return resp.json()["hash"] | |
def do_nintendo_oauth(): | |
# Handles the OAuth process, opening a URL in the user's browser and parses the resulting redirect URI to proceed with login | |
verifier, challenge = generate_challenge() | |
state = generate_state() | |
oauth_uri = "https://accounts.nintendo.com/connect/1.0.0/authorize?state={}&redirect_uri=npf71b963c1b7b6d119://auth&client_id=71b963c1b7b6d119&scope=openid%20user%20user.birthday%20user.mii%20user.screenName&response_type=session_token_code&session_token_code_challenge={}&session_token_code_challenge_method=S256&theme=login_form".format(state, challenge) | |
webbrowser.open(oauth_uri) | |
print("> First, visit this URL (should be open in browser):") | |
print(oauth_uri) | |
print() | |
print("> Once you're logged in, paste the URL of the *connection error page* below:") | |
print() | |
oauth_redirect_uri = input("> ").strip() | |
redirect_uri_parsed = parse_redirect_uri(oauth_redirect_uri) | |
if not redirect_uri_parsed: | |
print("Invalid redirect URI, aborting...") | |
sys.exit(1) | |
session_state, session_token_code, response_state = redirect_uri_parsed | |
if state != response_state: | |
print("Invalid redirect URI (bad OAuth state), aborting...") | |
sys.exit(1) | |
return session_token_code, verifier | |
def login_oauth_session(session_token_code, verifier): | |
# Handles the second step of the OAuth process using the information we got from the redirect API | |
resp = rsess.post("https://accounts.nintendo.com/connect/1.0.0/api/session_token", data={ | |
"client_id": nintendo_client_id, | |
"session_token_code": session_token_code, | |
"session_token_code_verifier": verifier | |
}, headers={ | |
"User-Agent": "OnlineLounge/1.6.1.2 NASDKAPI Android" | |
}) | |
if resp.status_code != 200: | |
print("Error obtaining session token from Nintendo, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
response_data = resp.json() | |
return response_data["session_token"] | |
def login_nintendo_api(session_token): | |
# This properly "logs in" to the Nintendo API getting us a token we can actually use for something practical | |
resp = rsess.post("https://accounts.nintendo.com/connect/1.0.0/api/token", data={ | |
"client_id": nintendo_client_id, | |
"session_token": session_token, | |
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer-session-token" | |
}, headers={ | |
"User-Agent": "OnlineLounge/1.6.1.2 NASDKAPI Android" | |
}) | |
if resp.status_code != 200: | |
print("Error obtaining service token from Nintendo, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
response_data = resp.json() | |
return response_data["id_token"], response_data["access_token"] | |
def get_nintendo_account_data(access_token): | |
# This fetches information about the currently logged-in user, including locale, country and birthday (needed later) | |
resp = rsess.get("https://api.accounts.nintendo.com/2.0.0/users/me", headers={ | |
"User-Agent": "OnlineLounge/1.6.1.2 NASDKAPI Android", | |
"Authorization": "Bearer {}".format(access_token) | |
}) | |
if resp.status_code != 200: | |
print("Error obtaining account data from Nintendo, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
return resp.json() | |
def login_switch_web(id_token, nintendo_profile): | |
# This logs into the Switch-specific API using a bit of a mess of third-party APIs to get the codes sorted | |
timestamp = str(int(datetime.datetime.utcnow().timestamp())) | |
request_id = str(uuid.uuid4()) | |
print("> Obtaining hash from Eli Fessler's S2S API...") | |
nso_hash = call_s2s(id_token, timestamp) | |
print("> Obtaining f-code from the flapg API...") | |
nso_f, _ = call_flapg(id_token, timestamp, request_id, nso_hash, "nso") | |
print("> Logging into Nintendo Switch API...") | |
resp = rsess.post("https://api-lp1.znc.srv.nintendo.net/v1/Account/Login", json={ | |
"parameter": { | |
"f": nso_f, | |
"naIdToken": id_token, | |
"timestamp": timestamp, | |
"requestId": request_id, | |
"naBirthday": nintendo_profile["birthday"], | |
"naCountry": nintendo_profile["country"], | |
"language": nintendo_profile["language"] | |
} | |
}, headers={ | |
"Content-Type": "application/json; charset=utf-8", | |
"User-Agent": "com.nintendo.znca/1.6.1.2 (Android/7.1.2)", | |
"X-ProductVersion": "1.6.1.2", | |
"X-Platform": "Android" | |
}) | |
if resp.status_code != 200 or "errorMessage" in resp.json(): | |
print("Error logging into Switch API, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
web_token = resp.json()["result"]["webApiServerCredential"]["accessToken"] | |
return web_token | |
def login_switch_game(game_id, web_token): | |
# This logs into the game-specific Switch API and gets us a "game web token" we can use on the AC:NH API servers | |
# Same round of f-code nonsense from before. | |
timestamp = str(int(datetime.datetime.utcnow().timestamp())) | |
request_id = str(uuid.uuid4()) | |
print("> Obtaining hash from Eli Fessler's S2S API...") | |
web_hash = call_s2s(web_token, timestamp) | |
print("> Obtaining f-code from the flapg API...") | |
app_f, app_token = call_flapg(web_token, timestamp, request_id, web_hash, "app") | |
print("> Logging into game API...") | |
resp = rsess.post("https://api-lp1.znc.srv.nintendo.net/v2/Game/GetWebServiceToken", json={ | |
"parameter": { | |
"id": 4953919198265344, | |
"f": app_f, | |
"registrationToken": web_token, | |
"timestamp": timestamp, | |
"requestId": request_id | |
} | |
}, headers={ | |
"Content-Type": "application/json; charset=utf-8", | |
"User-Agent": "com.nintendo.znca/1.6.1.2 (Android/7.1.2)", | |
"X-ProductVersion": "1.6.1.2", | |
"X-Platform": "Android", | |
"Authorization": "Bearer {}".format(web_token) | |
}) | |
if resp.status_code != 200 or "errorMessage" in resp.json(): | |
print("Error obtaining game web service token, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
game_token = resp.json()["result"]["accessToken"] | |
return game_token | |
def get_acnh_this_user(game_token): | |
# Gets information about the ACNH users on a given console, may return multiple results if you have multiple islanders | |
# You need to log in as a *specific* user from this list for any future calls | |
resp = rsess.get("https://web.sd.lp1.acbaa.srv.nintendo.net/api/sd/v1/users", headers={ | |
"Cookie": "_gtoken={}".format(game_token), | |
"User-Agent": browser_agent | |
}) | |
if resp.status_code != 200: | |
print("Error fetching ACNH user data, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
return resp.json()["users"][0] | |
def login_acnh(game_token, user_id): | |
# Now we can finally log into the AC:NH API with our Game Web Token and get a token we can use for ACNH API calls | |
# This requires the ACNH user ID, which we can get from the "this user" endpoint (which, as the only ACNH endpoint, needs the GWT instead) | |
resp = rsess.post("https://web.sd.lp1.acbaa.srv.nintendo.net/api/sd/v1/auth_token", json={ | |
"userId": user_id | |
}, headers={ | |
"Cookie": "_gtoken={}".format(game_token), | |
"User-Agent": browser_agent, | |
"Content-Type": "application/json; charset=utf-8" | |
}) | |
if resp.status_code >= 400: | |
print("Error logging into ACNH API, aborting... ({}; {})".format(resp, resp.text)) | |
sys.exit(1) | |
return resp.json()["token"] | |
def get_acnh_island(acnh_token, island_id): | |
# Gets information about an ACNH island by ID, will only accept *your own* island (based on who owns the auth token), 403s otherwise | |
resp = rsess.get("https://web.sd.lp1.acbaa.srv.nintendo.net/api/sd/v1/lands/{}/profile?language=en-US".format(island_id), headers={ | |
"User-Agent": browser_agent, | |
"Authorization": "Bearer {}".format(acnh_token) | |
}) | |
if resp.status_code != 200: | |
print("Error fetching ACNH island data, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
return resp.json() | |
def get_acnh_profile(acnh_token, user_id): | |
# Gets information about an ACNH user profile by ID, will only accept your own user OR your best friends' users (based on who owns the auth token), 403s otherwise | |
resp = rsess.get("https://web.sd.lp1.acbaa.srv.nintendo.net/api/sd/v1/users/{}/profile?language=en-US".format(user_id), headers={ | |
"User-Agent": browser_agent, | |
"Authorization": "Bearer {}".format(acnh_token) | |
}) | |
if resp.status_code != 200: | |
print("Error fetching ACNH profile data, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
return resp.json() | |
def get_acnh_friends(acnh_token): | |
# Gets the friend list of the user associated with the token | |
resp = rsess.get("https://web.sd.lp1.acbaa.srv.nintendo.net/api/sd/v1/friends", headers={ | |
"User-Agent": browser_agent, | |
"Authorization": "Bearer {}".format(acnh_token) | |
}) | |
if resp.status_code != 200: | |
print("Error fetching ACNH friend data, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
return resp.json()["friends"] | |
def get_acnh_friend_presences(acnh_token): | |
# Gets the list of online/away friends of the user associated with the token | |
# Returns a list of presence objects each containing a user ID and "state" integer, 1 for away, 2 for online | |
# Offline friends are not included. | |
resp = rsess.get("https://web.sd.lp1.acbaa.srv.nintendo.net/api/sd/v1/friends/presences", headers={ | |
"User-Agent": browser_agent, | |
"Authorization": "Bearer {}".format(acnh_token) | |
}) | |
if resp.status_code != 200: | |
print("Error fetching ACNH friend presence data, aborting... ({})".format(resp.text)) | |
sys.exit(1) | |
return resp.json()["presences"] | |
print("STEP 1: Opening web browser to OAuth login screen.") | |
session_token_code, verifier = do_nintendo_oauth() | |
print("STEP 2: Logging into Nintendo API...") | |
session_token = login_oauth_session(session_token_code, verifier) | |
id_token, access_token = login_nintendo_api(session_token) | |
print("STEP 3: Logging into Switch API...") | |
nintendo_account_data = get_nintendo_account_data(access_token) | |
switch_web_token = login_switch_web(id_token, nintendo_account_data) | |
print("STEP 4: Logging into game API...") | |
# This is the ID of AC:NH, as opposed to other games the server supports (SSBU or Splatoon 2) | |
acnh_game_token = login_switch_game(4953919198265344, switch_web_token) | |
acnh_user = get_acnh_this_user(acnh_game_token) | |
acnh_token = login_acnh(acnh_game_token, acnh_user["id"]) | |
print() | |
# We've logged into everything, can start doing the fun stuff | |
print("- MY USER") | |
acnh_profile = get_acnh_profile(acnh_token, acnh_user["id"]) | |
acnh_island = get_acnh_island(acnh_token, acnh_user["land"]["id"]) | |
print("Name: {}".format(acnh_profile["mPNm"])) | |
print("Tag: {}".format(acnh_profile["mHandleName"])) | |
print("Comment: {}".format(acnh_profile["mComment"])) | |
print("Birthday: {}/{}".format(acnh_profile["mBirth"]["month"], acnh_profile["mBirth"]["day"])) | |
print("Played since: {}-{:02d}-{:02d}".format(acnh_profile["mTimeStamp"]["year"], acnh_profile["mTimeStamp"]["month"], acnh_profile["mTimeStamp"]["day"])) | |
print("Fruit: {}".format(acnh_island["mFruit"]["name"])) | |
print("Villagers: {}".format(", ".join([npc["name"] for npc in acnh_island["mNormalNpc"]]))) | |
print("Currently online: {}".format(len(acnh_island["mVillager"]))) | |
print("User ID: {}, Island ID: {}".format(acnh_user["id"], acnh_user["land"]["id"])) | |
print() | |
print("- FRIENDS") | |
friends = get_acnh_friends(acnh_token) | |
friend_presences = {friend["userId"]: friend["state"] for friend in get_acnh_friend_presences(acnh_token)} | |
for friend in friends: | |
friend_profile = get_acnh_profile(acnh_token, friend["userId"]) | |
name = friend_profile["mPNm"] | |
island_name = friend_profile["landName"] | |
birthday = "{}/{}".format(friend_profile["mBirth"]["month"], friend_profile["mBirth"]["day"]) | |
tag = friend_profile["mHandleName"] | |
comment = friend_profile["mComment"] | |
played_since = "{}-{:02d}-{:02d}".format(friend_profile["mTimeStamp"]["year"], friend_profile["mTimeStamp"]["month"], friend_profile["mTimeStamp"]["day"]) | |
presence = {None: "Offline", 1: "Online", 2: "Away"}[friend_presences.get(friend["userId"])] | |
print("{} ({}, {}), Tag: {}, Comment: {}, Birthday: {}, Played since: {} (User ID: {}, Island ID: {})".format(name, island_name, presence, tag, comment, birthday, played_since, friend["userId"], friend["land"]["id"])) | |
print() | |
print("- TOKENS") | |
print("NA SESSION TOKEN:", session_token) | |
print("NA ID TOKEN:", id_token) | |
print("NA ACCESS TOKEN:", access_token) | |
print("SWITCH WEB TOKEN:", switch_web_token) | |
print("GAME WEB TOKEN:", acnh_game_token) | |
print("ACNH TOKEN:", acnh_token) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment