feat: get gtoken and bullet token
parent
dd55ec6072
commit
ac8e5d47c0
381
iksm.ts
381
iksm.ts
|
|
@ -1,53 +1,26 @@
|
||||||
import { CookieJar, wrapFetch } from "./deps.ts";
|
import { CookieJar, wrapFetch } from "./deps.ts";
|
||||||
import { LoginState } from "./state.ts";
|
import { readline, retry, urlBase64Encode } from "./utils.ts";
|
||||||
import { readline, urlBase64Encode } from "./utils.ts";
|
import { S3SI_VERSION } from "./version.ts";
|
||||||
|
|
||||||
const NSOAPP_VERSION = "2.3.1";
|
const NSOAPP_VERSION = "2.3.1";
|
||||||
|
const USERAGENT = `s3si.ts/${S3SI_VERSION}`;
|
||||||
|
|
||||||
function random(size: number): ArrayBuffer {
|
export class APIError extends Error {
|
||||||
return crypto.getRandomValues(new Uint8Array(size)).buffer;
|
response: Response;
|
||||||
|
json: unknown;
|
||||||
|
constructor(
|
||||||
|
{ response, message }: {
|
||||||
|
response: Response;
|
||||||
|
json?: unknown;
|
||||||
|
message?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSessionToken({
|
export async function loginManually(): Promise<string> {
|
||||||
cookieJar,
|
|
||||||
sessionTokenCode,
|
|
||||||
authCodeVerifier,
|
|
||||||
}: {
|
|
||||||
cookieJar: CookieJar;
|
|
||||||
sessionTokenCode: string;
|
|
||||||
authCodeVerifier: string;
|
|
||||||
}): Promise<string | undefined> {
|
|
||||||
const fetch = wrapFetch({ cookieJar });
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
"User-Agent": `OnlineLounge/${NSOAPP_VERSION} NASDKAPI Android`,
|
|
||||||
"Accept-Language": "en-US",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Content-Length": "540",
|
|
||||||
"Host": "accounts.nintendo.com",
|
|
||||||
"Connection": "Keep-Alive",
|
|
||||||
"Accept-Encoding": "gzip",
|
|
||||||
};
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
"client_id": "71b963c1b7b6d119",
|
|
||||||
"session_token_code": sessionTokenCode,
|
|
||||||
"session_token_code_verifier": authCodeVerifier,
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = "https://accounts.nintendo.com/connect/1.0.0/api/session_token";
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: "POST",
|
|
||||||
headers: headers,
|
|
||||||
body: new URLSearchParams(body),
|
|
||||||
});
|
|
||||||
const resBody = await res.json();
|
|
||||||
return resBody["session_token"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loginManually(): Promise<LoginState> {
|
|
||||||
const cookieJar = new CookieJar();
|
const cookieJar = new CookieJar();
|
||||||
const fetch = wrapFetch({ cookieJar });
|
const fetch = wrapFetch({ cookieJar });
|
||||||
|
|
||||||
|
|
@ -119,7 +92,325 @@ export async function loginManually(): Promise<LoginState> {
|
||||||
throw new Error("No session token found");
|
throw new Error("No session token found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGToken(
|
||||||
|
{ fApi, sessionToken }: { fApi: string; sessionToken: string },
|
||||||
|
) {
|
||||||
|
const idResp = await fetch(
|
||||||
|
"https://accounts.nintendo.com/connect/1.0.0/api/token",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Host": "accounts.nintendo.com",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Connection": "Keep-Alive",
|
||||||
|
"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 7.1.2)",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
"client_id": "71b963c1b7b6d119",
|
||||||
|
"session_token": sessionToken,
|
||||||
|
"grant_type":
|
||||||
|
"urn:ietf:params:oauth:grant-type:jwt-bearer-session-token",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const idRespJson = await idResp.json();
|
||||||
|
const { access_token: accessToken, id_token: idToken } = idRespJson;
|
||||||
|
if (!accessToken || !idToken) {
|
||||||
|
throw new APIError({
|
||||||
|
response: idResp,
|
||||||
|
json: idRespJson,
|
||||||
|
message: "No access_token or id_token found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const uiResp = await fetch(
|
||||||
|
"https://api.accounts.nintendo.com/2.0.0/users/me",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "NASDKAPI; Android",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
|
"Host": "api.accounts.nintendo.com",
|
||||||
|
"Connection": "Keep-Alive",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const uiRespJson = await uiResp.json();
|
||||||
|
const { nickname, birthday, language, country } = uiRespJson;
|
||||||
|
|
||||||
|
const getIdToken2 = async (idToken: string) => {
|
||||||
|
const { f, request_id: requestId, timestamp } = await callImink({
|
||||||
|
fApi,
|
||||||
|
step: 1,
|
||||||
|
idToken,
|
||||||
|
});
|
||||||
|
const parameter = {
|
||||||
|
"f": f,
|
||||||
|
"language": language,
|
||||||
|
"naBirthday": birthday,
|
||||||
|
"naCountry": country,
|
||||||
|
"naIdToken": idToken,
|
||||||
|
"requestId": requestId,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
};
|
||||||
|
const resp = await fetch(
|
||||||
|
"https://api-lp1.znc.srv.nintendo.net/v3/Account/Login",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Platform": "Android",
|
||||||
|
"X-ProductVersion": NSOAPP_VERSION,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Connection": "Keep-Alive",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"User-Agent": `com.nintendo.znca/${NSOAPP_VERSION}(Android/7.1.2)`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
parameter,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const respJson = await resp.json();
|
||||||
|
|
||||||
|
const idToken2 = respJson?.result?.webApiServerCredential?.accessToken;
|
||||||
|
|
||||||
|
if (!idToken2) {
|
||||||
|
throw new APIError({
|
||||||
|
response: resp,
|
||||||
|
json: respJson,
|
||||||
|
message: "No idToken2 found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return idToken2 as string;
|
||||||
|
};
|
||||||
|
const getGToken = async (idToken: string) => {
|
||||||
|
const { f, request_id: requestId, timestamp } = await callImink({
|
||||||
|
step: 2,
|
||||||
|
idToken,
|
||||||
|
fApi,
|
||||||
|
});
|
||||||
|
const resp = await fetch(
|
||||||
|
"https://api-lp1.znc.srv.nintendo.net/v2/Game/GetWebServiceToken",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Platform": "Android",
|
||||||
|
"X-ProductVersion": NSOAPP_VERSION,
|
||||||
|
"Authorization": `Bearer ${idToken}`,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"User-Agent": `com.nintendo.znca/${NSOAPP_VERSION}(Android/7.1.2)`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
parameter: {
|
||||||
|
"f": f,
|
||||||
|
"id": 4834290508791808,
|
||||||
|
"registrationToken": idToken,
|
||||||
|
"requestId": requestId,
|
||||||
|
"timestamp": timestamp,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const respJson = await resp.json();
|
||||||
|
|
||||||
|
const webServiceToken = respJson?.result?.accessToken;
|
||||||
|
|
||||||
|
if (!webServiceToken) {
|
||||||
|
throw new APIError({
|
||||||
|
response: resp,
|
||||||
|
json: respJson,
|
||||||
|
message: "No webServiceToken found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return webServiceToken as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const idToken2 = await retry(() => getIdToken2(idToken));
|
||||||
|
const webServiceToken = await retry(() => getGToken(idToken2));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessionToken,
|
webServiceToken,
|
||||||
|
nickname,
|
||||||
|
userCountry: country,
|
||||||
|
userLang: language,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SPLATNET3_URL = "https://api.lp1.av5ja.srv.nintendo.net";
|
||||||
|
|
||||||
|
async function getWebViewVer(): Promise<string> {
|
||||||
|
const splatnet3Home = await (await fetch(SPLATNET3_URL)).text();
|
||||||
|
|
||||||
|
const mainJS = /src="(\/.*?\.js)"/.exec(splatnet3Home)?.[1];
|
||||||
|
|
||||||
|
if (!mainJS) {
|
||||||
|
throw new Error("No main.js found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainJSBody = await (await fetch(SPLATNET3_URL + mainJS)).text();
|
||||||
|
|
||||||
|
const revision = /"([0-9a-f]{40})"/.exec(mainJSBody)?.[1];
|
||||||
|
const version = /revision_info_not_set.*?="(\d+\.\d+\.\d+)/.exec(mainJSBody)
|
||||||
|
?.[1];
|
||||||
|
|
||||||
|
if (!version || !revision) {
|
||||||
|
throw new Error("No version and revision found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${version}-${revision.substring(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_APP_USER_AGENT = "Mozilla/5.0 (Linux; Android 11; Pixel 5) " +
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||||
|
"Chrome/94.0.4606.61 Mobile Safari/537.36";
|
||||||
|
|
||||||
|
export async function getBulletToken(
|
||||||
|
{
|
||||||
|
webServiceToken,
|
||||||
|
appUserAgent = DEFAULT_APP_USER_AGENT,
|
||||||
|
userLang,
|
||||||
|
userCountry,
|
||||||
|
}: {
|
||||||
|
webServiceToken: string;
|
||||||
|
appUserAgent?: string;
|
||||||
|
userLang: string;
|
||||||
|
userCountry: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const webViewVer = await getWebViewVer();
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept-Language": userLang,
|
||||||
|
"User-Agent": appUserAgent,
|
||||||
|
"X-Web-View-Ver": webViewVer,
|
||||||
|
"X-NACOUNTRY": userCountry,
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Origin": "https://api.lp1.av5ja.srv.nintendo.net",
|
||||||
|
"X-Requested-With": "com.nintendo.znca",
|
||||||
|
"Cookie": `_gtoken=${webServiceToken}`,
|
||||||
|
};
|
||||||
|
const resp = await fetch(
|
||||||
|
"https://api.lp1.av5ja.srv.nintendo.net/api/bullet_tokens",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resp.status == 401) {
|
||||||
|
throw new APIError({
|
||||||
|
response: resp,
|
||||||
|
message:
|
||||||
|
"Unauthorized error (ERROR_INVALID_GAME_WEB_TOKEN). Cannot fetch tokens at this time.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (resp.status == 403) {
|
||||||
|
throw new APIError({
|
||||||
|
response: resp,
|
||||||
|
message:
|
||||||
|
"Forbidden error (ERROR_OBSOLETE_VERSION). Cannot fetch tokens at this time.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (resp.status == 204) {
|
||||||
|
throw new APIError({
|
||||||
|
response: resp,
|
||||||
|
message: "Cannot access SplatNet 3 without having played online.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (resp.status !== 201) {
|
||||||
|
throw new APIError({
|
||||||
|
response: resp,
|
||||||
|
message: "Not 201",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const respJson = await resp.json();
|
||||||
|
const { bulletToken } = respJson;
|
||||||
|
|
||||||
|
if (typeof bulletToken !== "string") {
|
||||||
|
throw new APIError({
|
||||||
|
response: resp,
|
||||||
|
json: respJson,
|
||||||
|
message: "No bulletToken found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return bulletToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function random(size: number): ArrayBuffer {
|
||||||
|
return crypto.getRandomValues(new Uint8Array(size)).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSessionToken({
|
||||||
|
cookieJar,
|
||||||
|
sessionTokenCode,
|
||||||
|
authCodeVerifier,
|
||||||
|
}: {
|
||||||
|
cookieJar: CookieJar;
|
||||||
|
sessionTokenCode: string;
|
||||||
|
authCodeVerifier: string;
|
||||||
|
}): Promise<string | undefined> {
|
||||||
|
const fetch = wrapFetch({ cookieJar });
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"User-Agent": `OnlineLounge/${NSOAPP_VERSION} NASDKAPI Android`,
|
||||||
|
"Accept-Language": "en-US",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Host": "accounts.nintendo.com",
|
||||||
|
"Connection": "Keep-Alive",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
"client_id": "71b963c1b7b6d119",
|
||||||
|
"session_token_code": sessionTokenCode,
|
||||||
|
"session_token_code_verifier": authCodeVerifier,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = "https://accounts.nintendo.com/connect/1.0.0/api/session_token";
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: headers,
|
||||||
|
body: new URLSearchParams(body),
|
||||||
|
});
|
||||||
|
const resBody = await res.json();
|
||||||
|
return resBody["session_token"];
|
||||||
|
}
|
||||||
|
|
||||||
|
type IminkResponse = {
|
||||||
|
f: string;
|
||||||
|
request_id: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
async function callImink(
|
||||||
|
{ fApi, step, idToken }: { fApi: string; step: number; idToken: string },
|
||||||
|
): Promise<IminkResponse> {
|
||||||
|
const headers = {
|
||||||
|
"User-Agent": USERAGENT,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
};
|
||||||
|
const body = {
|
||||||
|
"token": idToken,
|
||||||
|
"hashMethod": step,
|
||||||
|
};
|
||||||
|
const resp = await fetch(fApi, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
|
||||||
58
s3si.ts
58
s3si.ts
|
|
@ -1,4 +1,4 @@
|
||||||
import { loginManually } from "./iksm.ts";
|
import { APIError, getBulletToken, getGToken, loginManually } from "./iksm.ts";
|
||||||
import { flags } from "./deps.ts";
|
import { flags } from "./deps.ts";
|
||||||
import { DEFAULT_STATE, State } from "./state.ts";
|
import { DEFAULT_STATE, State } from "./state.ts";
|
||||||
|
|
||||||
|
|
@ -29,15 +29,19 @@ Options:
|
||||||
async writeState() {
|
async writeState() {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const data = encoder.encode(JSON.stringify(this.state, undefined, 2));
|
const data = encoder.encode(JSON.stringify(this.state, undefined, 2));
|
||||||
await Deno.writeFile(this.opts.configPath + ".swap", data);
|
const swapPath = `${this.opts.configPath}.swap`;
|
||||||
await Deno.rename(this.opts.configPath + ".swap", this.opts.configPath);
|
await Deno.writeFile(swapPath, data);
|
||||||
|
await Deno.rename(swapPath, this.opts.configPath);
|
||||||
}
|
}
|
||||||
async readState() {
|
async readState() {
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
try {
|
try {
|
||||||
const data = await Deno.readFile(this.opts.configPath);
|
const data = await Deno.readFile(this.opts.configPath);
|
||||||
const json = JSON.parse(decoder.decode(data));
|
const json = JSON.parse(decoder.decode(data));
|
||||||
this.state = json;
|
this.state = {
|
||||||
|
...DEFAULT_STATE,
|
||||||
|
...json,
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Failed to read config file, create new config file. (${e})`,
|
`Failed to read config file, create new config file. (${e})`,
|
||||||
|
|
@ -47,13 +51,45 @@ Options:
|
||||||
}
|
}
|
||||||
async run() {
|
async run() {
|
||||||
await this.readState();
|
await this.readState();
|
||||||
if (!this.state.loginState?.sessionToken) {
|
|
||||||
const { sessionToken } = await loginManually();
|
try {
|
||||||
this.state.loginState = {
|
if (!this.state.loginState?.sessionToken) {
|
||||||
...this.state.loginState,
|
const sessionToken = await loginManually();
|
||||||
sessionToken,
|
this.state.loginState = {
|
||||||
};
|
...this.state.loginState,
|
||||||
await this.writeState();
|
sessionToken,
|
||||||
|
};
|
||||||
|
await this.writeState();
|
||||||
|
}
|
||||||
|
const sessionToken = this.state.loginState.sessionToken!;
|
||||||
|
|
||||||
|
if (!this.state.loginState?.gToken) {
|
||||||
|
const { webServiceToken, userCountry, userLang } = await getGToken({
|
||||||
|
fApi: this.state.fGen,
|
||||||
|
sessionToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulletToken = await getBulletToken({
|
||||||
|
webServiceToken,
|
||||||
|
userLang,
|
||||||
|
userCountry,
|
||||||
|
appUserAgent: this.state.appUserAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.state.loginState = {
|
||||||
|
...this.state.loginState,
|
||||||
|
gToken: webServiceToken,
|
||||||
|
bulletToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.writeState();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof APIError) {
|
||||||
|
console.error(`APIError: ${e.message}`, e.response, e.json);
|
||||||
|
} else {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
state.ts
10
state.ts
|
|
@ -1,8 +1,14 @@
|
||||||
export type LoginState = {
|
export type LoginState = {
|
||||||
sessionToken: string;
|
sessionToken?: string;
|
||||||
|
gToken?: string;
|
||||||
|
bulletToken?: string;
|
||||||
};
|
};
|
||||||
export type State = {
|
export type State = {
|
||||||
loginState?: LoginState;
|
loginState?: LoginState;
|
||||||
|
fGen: string;
|
||||||
|
appUserAgent?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_STATE: State = {};
|
export const DEFAULT_STATE: State = {
|
||||||
|
fGen: "https://api.imink.app/f",
|
||||||
|
};
|
||||||
|
|
|
||||||
16
utils.ts
16
utils.ts
|
|
@ -24,3 +24,19 @@ export async function readline() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PromiseReturnType<T> = T extends () => Promise<infer R> ? R : never;
|
||||||
|
export async function retry<F extends () => Promise<unknown>>(
|
||||||
|
f: F,
|
||||||
|
times = 2,
|
||||||
|
): Promise<PromiseReturnType<F>> {
|
||||||
|
let lastError;
|
||||||
|
for (let i = 0; i < times; i++) {
|
||||||
|
try {
|
||||||
|
return await f() as PromiseReturnType<F>;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export const S3SI_VERSION = "0.1.0";
|
||||||
Loading…
Reference in New Issue