Add auth test

This commit is contained in:
daydreamer-json
2026-01-23 02:40:37 +09:00
parent 070627b522
commit 11f2e256c3
13 changed files with 616 additions and 15 deletions

View File

@@ -42,6 +42,27 @@ async function parseCommand() {
},
wrapHandler(cmds.test),
)
.command(
['authTest [token] [email] [password]'],
'Auth test command',
(yargs) => {
yargs
.positional('token', {
describe: 'Gryphline account service token',
type: 'string',
})
.positional('email', {
describe: 'Gryphline account email address',
type: 'string',
})
.positional('password', {
describe: 'Gryphline account password',
type: 'string',
})
.options({});
},
wrapHandler(cmds.authTest),
)
.options({
'log-level': {
desc: 'Set log level (' + TypesLogLevels.LOG_LEVELS_NUM.join(', ') + ')',

View File

@@ -1,5 +1,7 @@
import authTest from './cmds/authTest.js';
import test from './cmds/test.js';
export default {
authTest,
test,
};

163
src/cmds/authTest.ts Normal file
View File

@@ -0,0 +1,163 @@
import chalk from 'chalk';
import CliTable3 from 'cli-table3';
import { HTTPError } from 'ky';
import prompts from 'prompts';
import apiUtils from '../utils/api.js';
import argvUtils from '../utils/argv.js';
import appConfig from '../utils/config.js';
import exitUtils from '../utils/exit.js';
import logger from '../utils/logger.js';
import termPrettyUtils from '../utils/termPretty.js';
async function mainCmdHandler() {
const cfg = appConfig.network.api.akEndfield;
// const channelStr = String(cfg.channel.osWinRel);
let needRetrieveToken = false;
let oauth2TokenPreRsp = null;
if (!('token' in argvUtils.getArgv()) || !argvUtils.getArgv()['token']) {
const tokenUserRsp: string = await (async () => {
logger.warn('Gryphline account service token has not been specified. Requesting ...');
const onCancelFn = () => {
logger.error('Aborted');
exitUtils.exit(1, null, false);
};
return (
await prompts(
{ name: 'value', type: 'password', message: 'Enter Gryphline account service token' },
{ onCancel: onCancelFn },
)
).value;
})();
if (tokenUserRsp === '') {
needRetrieveToken = true;
} else {
argvUtils.setArgv({ ...argvUtils.getArgv(), token: tokenUserRsp });
}
}
if (needRetrieveToken === false) {
try {
oauth2TokenPreRsp = await apiUtils.apiAkEndfield.accountService.user.oauth2.v2.grant(
cfg.appCode.accountService.osWinRel,
argvUtils.getArgv()['token'],
);
} catch (err) {
if (err instanceof HTTPError) {
if ((await err.response.json()).status === 3) needRetrieveToken = true;
} else {
throw err;
}
}
}
if (needRetrieveToken) {
await (async () => {
const onCancelFn = () => {
logger.error('Aborted');
exitUtils.exit(1, null, false);
};
if (!('email' in argvUtils.getArgv())) {
logger.warn('Gryphline account email has not been specified. Requesting ...');
const emailRsp: number = (
await prompts(
{
...{ name: 'value', type: 'text', message: 'Enter Gryphline account email' },
validate: (value) => (Boolean(value) ? true : 'Invalid value'),
},
{ onCancel: onCancelFn },
)
).value;
argvUtils.setArgv({ ...argvUtils.getArgv(), email: emailRsp });
}
if (!('password' in argvUtils.getArgv())) {
// logger.warn('Gryphline account password has not been specified. Requesting ...');
const pwdRsp: number = (
await prompts(
{
...{ name: 'value', type: 'password', message: 'Enter Gryphline account password' },
validate: (value) => (Boolean(value) ? true : 'Invalid value'),
},
{ onCancel: onCancelFn },
)
).value;
argvUtils.setArgv({ ...argvUtils.getArgv(), password: pwdRsp });
}
})();
logger.info('Retrieving account service token ...');
const accSrvTokenRsp = await apiUtils.apiAkEndfield.accountService.user.auth.v1.tokenByEmailPassword(
argvUtils.getArgv()['email'],
argvUtils.getArgv()['password'],
);
argvUtils.setArgv({ ...argvUtils.getArgv(), token: accSrvTokenRsp.data.token });
}
logger.info('Retrieving account service OAuth 2.0 token ...');
const oauth2TokenRsp = await apiUtils.apiAkEndfield.accountService.user.oauth2.v2.grant(
cfg.appCode.accountService.osWinRel,
argvUtils.getArgv()['token'],
);
logger.info('Retrieving u8 access token ...');
const u8TokenRsp = await apiUtils.apiAkEndfield.u8.user.auth.v2.tokenByChToken(
cfg.appCode.u8.osWinRel,
cfg.channel.osWinRel,
oauth2TokenRsp.data.code,
);
logger.info('Authentication successful!');
logger.info('Retrieving user account data ...');
const userAccData = await apiUtils.apiAkEndfield.accountService.user.info.v1.basic(
cfg.appCode.accountService.osWinRel,
argvUtils.getArgv()['token'],
);
logger.info('Retrieving user game server data ...');
const userGameData = await apiUtils.apiAkEndfield.u8.game.server.v1.serverList(u8TokenRsp.data.token);
logger.info('Data retrieval completed!');
(() => {
const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded);
table.push(
...[
['Account ID', userAccData.data.hgId],
['Email', userAccData.data.realEmail],
['Nickname', userAccData.data.nickName === '' ? chalk.dim('(none)') : userAccData.data.nickName],
['Age Region', userAccData.data.ageGate.regionInfo['en-us']],
].map((e) => [chalk.dim(e[0]), e[1]]),
);
console.log(table.toString());
})();
(() => {
const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded);
table.push(
...[
['ID', 'Time', 'Name', 'Domain', 'Port'].map((e) => chalk.dim(e)),
...userGameData.data.serverList.map((e) => [
e.serverId,
'UTC' + (JSON.parse(e.extension).offsetSeconds < 0 ? '' : '+') + JSON.parse(e.extension).offsetSeconds / 3600,
e.serverName,
JSON.parse(e.serverDomain)[0].host,
JSON.parse(e.serverDomain)[0].port,
]),
],
);
console.log(table.toString());
})();
(() => {
const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded);
table.push(
...[
['ID', 'UID', 'Level', 'Default'].map((e) => chalk.dim(e)),
...userGameData.data.serverList.map((e) => [
e.serverId,
e.roleId,
{ hAlign: 'right' as const, content: e.level },
e.defaultChoose,
]),
],
);
console.log(table.toString());
})();
}
export default mainCmdHandler;

View File

@@ -81,8 +81,8 @@ async function mainCmdHandler() {
await (async () => {
logger.debug('Fetching latestGame ...');
const rsp = await apiUtils.apiAkEndfield.launcher.latestGame(
cfg.appCode.osWinRel,
cfg.launcherAppCode.osWinRel,
cfg.appCode.game.osWinRel,
cfg.appCode.launcher.osWinRel,
cfg.channel.osWinRel,
cfg.channel.osWinRel,
cfg.channel.osWinRel,
@@ -103,8 +103,8 @@ async function mainCmdHandler() {
);
const prettyRsp = {
req: {
appCode: cfg.appCode.osWinRel,
launcherAppCode: cfg.launcherAppCode.osWinRel,
appCode: cfg.appCode.game.osWinRel,
launcherAppCode: cfg.appCode.launcher.osWinRel,
channel: cfg.channel.osWinRel,
subChannel: cfg.channel.osWinRel,
launcherSubChannel: cfg.channel.osWinRel,
@@ -147,7 +147,7 @@ async function mainCmdHandler() {
for (const versionInfoEntry of versionInfoList) {
if (!versionInfoEntry.randStr) throw new Error('version rand_str not found');
const rsp = await apiUtils.apiAkEndfield.launcher.latestGameResources(
cfg.appCode.osWinRel,
cfg.appCode.game.osWinRel,
versionInfoEntry.versionMinor,
versionInfoEntry.version,
versionInfoEntry.randStr,
@@ -155,7 +155,7 @@ async function mainCmdHandler() {
logger.info(`Fetched latestGameRes: v${versionInfoEntry.version}, ${rsp.res_version}`);
const prettyRsp = {
req: {
appCode: cfg.appCode.osWinRel,
appCode: cfg.appCode.game.osWinRel,
gameVersion: versionInfoEntry.versionMinor,
version: versionInfoEntry.version,
randStr: versionInfoEntry.randStr,
@@ -178,7 +178,7 @@ async function mainCmdHandler() {
const launcherTargetAppList = ['EndField', 'official'] as const;
for (const launcherTargetAppEntry of launcherTargetAppList) {
const rsp = await apiUtils.apiAkEndfield.launcher.latestLauncher(
cfg.launcherAppCode.osWinRel,
cfg.appCode.launcher.osWinRel,
cfg.channel.osWinRel,
cfg.channel.osWinRel,
null,
@@ -187,7 +187,7 @@ async function mainCmdHandler() {
logger.info(`Fetched latestLauncher: v${rsp.version}, ${launcherTargetAppEntry}`);
const prettyRsp = {
req: {
appCode: cfg.launcherAppCode.osWinRel,
appCode: cfg.appCode.launcher.osWinRel,
channel: cfg.channel.osWinRel,
subChannel: cfg.channel.osWinRel,
targetApp: launcherTargetAppEntry === 'official' ? null : launcherTargetAppEntry,

View File

@@ -106,6 +106,94 @@ type LauncherWebAnnouncement = {
}[];
};
type AccSrvUserAuthV1TokenByEmail = {
data: {
token: string;
hgId: string; // hypergryph account id
email: string; // obfuscated email
isLatestUserAgreement: boolean;
};
msg: string;
status: number;
type: string;
};
type AccSrvUserInfoV1Basic = {
data: {
hgId: string; // hypergryph account id
email: string; // obfuscated email
realEmail: string; // un-obfuscated email
isLatestUserAgreement: boolean;
nickName: string;
emailSubscription: boolean; // ???
extension: { firebaseHashedInfo: string };
ageGate: {
ageAuthState: number;
bindEmail: boolean;
parentAuthState: number;
regionInfo: Record<
| 'de-de'
| 'en-us' // Japan
| 'es-mx'
| 'fr-fr'
| 'id-id'
| 'it-it'
| 'ja-jp' // 日本
| 'ko-kr'
| 'pt-br'
| 'ru-ru'
| 'th-th'
| 'vi-vn'
| 'zh-cn' // 日本
| 'zh-tw',
string
>;
regionCode: string; // JP
};
};
msg: string;
status: number;
type: string;
};
type AccSrvUserOAuth2V2Grant = {
data: {
uid: string; // ???
code: string; // this is channel token
};
msg: string; // OK, Login status expired.
status: number; // 0=OK, 3=expired
type: string;
};
type U8UserAuthV2ChToken = {
data: {
token: string;
isNew: boolean;
uid: string; // number, game overall uid?
};
msg: string;
status: number;
type: string;
};
type U8GameServerV1ServerList = {
data: {
serverList: {
serverId: string; // number
serverName: string; // Asia
serverDomain: string; // jsonStr [{"host": "beyond-asiapacific.gryphline.com", "port": 30000}]
defaultChoose: boolean;
roleId: string; // the so-called UID elsewhere
level: number; // playerLv
extension: string; // jsonStr {"offsetSeconds": -18000, "monthlyCardOffsetSecond": -18000}
}[];
};
msg: string;
status: number;
type: string;
};
export type {
LauncherLatestGame,
LauncherLatestGameResources,
@@ -115,4 +203,9 @@ export type {
LauncherWebMainBgImage,
LauncherWebBanner,
LauncherWebAnnouncement,
AccSrvUserAuthV1TokenByEmail,
AccSrvUserInfoV1Basic,
AccSrvUserOAuth2V2Grant,
U8UserAuthV2ChToken,
U8GameServerV1ServerList,
};

View File

@@ -1,6 +1,6 @@
import ky from 'ky';
import semver from 'semver';
import * as TypesApiAkEndfield from '../types/api/akEndfield/api.js';
import * as TypesApiAkEndfield from '../types/api/akEndfield/Api.js';
import appConfig from './config.js';
const defaultKySettings = {
@@ -248,5 +248,104 @@ export default {
},
},
},
accountService: {
user: {
auth: {
v1: {
tokenByEmailPassword: async (
email: string,
password: string,
from: number = 0,
): Promise<TypesApiAkEndfield.AccSrvUserAuthV1TokenByEmail> => {
const rsp = await ky
.post(
`https://${appConfig.network.api.akEndfield.base.accountService}/user/auth/v1/token_by_email_password`,
{ ...defaultKySettings, json: { email, from, password } },
)
.json();
return rsp as TypesApiAkEndfield.AccSrvUserAuthV1TokenByEmail;
},
},
},
oauth2: {
v2: {
grant: async (
appCode: string,
token: string,
type: number = 0,
): Promise<TypesApiAkEndfield.AccSrvUserOAuth2V2Grant> => {
const rsp = await ky
.post(`https://${appConfig.network.api.akEndfield.base.accountService}/user/oauth2/v2/grant`, {
...defaultKySettings,
json: { appCode, token, type },
})
.json();
return rsp as TypesApiAkEndfield.AccSrvUserOAuth2V2Grant;
},
},
},
info: {
v1: {
basic: async (appCode: string, token: string): Promise<TypesApiAkEndfield.AccSrvUserInfoV1Basic> => {
const rsp = await ky
.get(`https://${appConfig.network.api.akEndfield.base.accountService}/user/info/v1/basic`, {
...defaultKySettings,
searchParams: { appCode, token },
})
.json();
return rsp as TypesApiAkEndfield.AccSrvUserInfoV1Basic;
},
},
},
},
},
u8: {
user: {
auth: {
v2: {
tokenByChToken: async (
appCode: string,
channelMasterId: number,
channelToken: string,
platform: number = 2,
type: number = 0,
): Promise<TypesApiAkEndfield.U8UserAuthV2ChToken> => {
const rsp = await ky
.post(`https://${appConfig.network.api.akEndfield.base.u8}/u8/user/auth/v2/token_by_channel_token`, {
...defaultKySettings,
json: {
appCode,
channelMasterId,
channelToken: JSON.stringify({
code: channelToken,
type: 1,
isSuc: true,
}),
type,
platform,
},
})
.json();
return rsp as TypesApiAkEndfield.U8UserAuthV2ChToken;
},
},
},
},
game: {
server: {
v1: {
serverList: async (token: string): Promise<TypesApiAkEndfield.U8GameServerV1ServerList> => {
const rsp = await ky
.post(`https://${appConfig.network.api.akEndfield.base.u8}/game/server/v1/server_list`, {
...defaultKySettings,
json: { token },
})
.json();
return rsp as TypesApiAkEndfield.U8GameServerV1ServerList;
},
},
},
},
},
},
};

View File

@@ -15,8 +15,12 @@ type ConfigType = AllRequired<
network: {
api: {
akEndfield: {
appCode: { osWinRel: string };
launcherAppCode: { osWinRel: string };
appCode: {
game: { osWinRel: string };
launcher: { osWinRel: string };
accountService: { osWinRel: string };
u8: { osWinRel: string };
};
channel: { osWinRel: number };
base: {
accountService: string;
@@ -55,8 +59,12 @@ const initialConfig: ConfigType = {
network: {
api: {
akEndfield: {
appCode: { osWinRel: 'YDUTE5gscDZ229CW' },
launcherAppCode: { osWinRel: 'TiaytKBUIEdoEwRT' },
appCode: {
game: { osWinRel: 'YDUTE5gscDZ229CW' },
launcher: { osWinRel: 'TiaytKBUIEdoEwRT' },
accountService: { osWinRel: 'd9f6dbb6bbd6bb33' },
u8: { osWinRel: '973bd727dd11cbb6ead8' },
},
channel: { osWinRel: 6 },
base: {
accountService: 'YXMuZ3J5cGhsaW5lLmNvbQ==',

148
src/utils/string.ts Normal file
View File

@@ -0,0 +1,148 @@
// === Generated by Qwen3-Max ===
/**
* 元となるURLと相対URLから絶対URLを解決します。
* ベースURLがファイルパスを含む場合でも、そのファイルのディレクトリを基準に解決します。
*
* @param baseUrl - 元となるURL絶対URL
* @param relativeUrl - 相対URLまたは絶対URL
* @returns 解決された絶対URL
* @throws URLが無効な場合にエラーをスローします
*/
function resolveUrl(baseUrl: string, relativeUrl: string): string {
try {
// 相対URLがすでに絶対URLの場合、そのまま返す
if (isAbsoluteUrl(relativeUrl)) {
return new URL(relativeUrl).href;
}
// baseUrlをURLとしてパース
const base = new URL(baseUrl);
// ベースURLがファイルパス末尾が/でない)の場合、
// ファイル名を除去してディレクトリパスにする
if (!base.pathname.endsWith('/')) {
// 最後のスラッシュ以降を除去(ファイル名を削除)
const lastSlashIndex = base.pathname.lastIndexOf('/');
if (lastSlashIndex !== -1) {
base.pathname = base.pathname.substring(0, lastSlashIndex + 1);
} else {
// パスにスラッシュが含まれていない稀なケース
base.pathname = '/';
}
}
// 相対URLを解決
const resolved = new URL(relativeUrl, base);
return resolved.href;
} catch (error) {
throw new Error(`URLの解決に失敗しました: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* URLが絶対URLかどうかを判定します。
*
* @param url - 判定するURL文字列
* @returns 絶対URLの場合はtrue、それ以外はfalse
*/
function isAbsoluteUrl(url: string): boolean {
try {
return Boolean(new URL(url).protocol);
} catch {
return false;
}
}
/**
* URLフルパスからファイル名を除いた部分を抽出します
* @param url
* @returns
*/
function getBaseUrlWithoutLastSegment(url: string): string {
const u = new URL(url);
const dirPath = u.pathname.split('/').slice(0, -1).join('/');
return `${u.origin}${dirPath}`;
}
// ==============================
function replaceMultiPatterns(replacements: [RegExp, string][], originalString: string): string {
return replacements.reduce((currentString, [pattern, replacement]) => {
return currentString.replace(pattern, replacement);
}, originalString);
}
function sanitizeFilename(filename: string) {
const invalidCharsMap: Record<string, string> = {
'\\': '',
'/': '',
':': '',
'*': '',
'?': '',
'"': '',
'<': '',
'>': '',
'|': '',
'&': '',
};
return Array.from(filename)
.map((char) => invalidCharsMap[char] || char)
.join('');
}
/**
* 文字列が指定された正規表現パターンにマッチするかを判定する関数
*
* @param inputStr - チェック対象の文字列
* @param regexInclude - マッチが必須の正規表現パターン少なくとも1つマッチする必要あり
* @param regexExclude - マッチが禁止の正規表現パターン1つもマッチしてはならない
* @returns
* includeパターンが空の場合はexcludeにマッチしない限りtrue
* includeパターンがある場合は、少なくとも1つのincludeにマッチし、
* かつ全てのexcludeにマッチしない場合のみtrue
*/
function filterByRegex(
inputStr: string,
regexInclude: readonly (RegExp | string)[],
regexExclude: readonly (RegExp | string)[],
): boolean {
// 文字列をRegExpに変換するヘルパー関数グローバルフラグを無効化
const toRegExp = (pattern: RegExp | string): RegExp => {
if (pattern instanceof RegExp) {
// グローバルフラグ/ステッキーフラグを無効化test()の状態問題を回避)
const flags = pattern.flags.replace(/[gy]/g, '');
return new RegExp(pattern.source, flags);
}
return new RegExp(pattern);
};
// 正規表現を変換include/exclude両方
const includePatterns = regexInclude.map(toRegExp);
const excludePatterns = regexExclude.map(toRegExp);
// 1. excludeパターンに1つでもマッチしたら即座にfalse
if (excludePatterns.some((pattern) => pattern.test(inputStr))) {
return false;
}
// 2. includeパターンが空の場合はtrueexcludeを通過した時点でOK
if (includePatterns.length === 0) {
return true;
}
// 3. includeパターンに1つでもマッチすればtrue
return includePatterns.some((pattern) => pattern.test(inputStr));
}
// ==============================
export default {
resolveUrl,
isAbsoluteUrl,
getBaseUrlWithoutLastSegment,
replaceMultiPatterns,
sanitizeFilename,
filterByRegex,
};

46
src/utils/termPretty.ts Normal file
View File

@@ -0,0 +1,46 @@
const cliTableConfig = {
rounded: {
chars: {
top: '─',
'top-mid': '',
'top-left': '╭',
'top-right': '╮',
bottom: '─',
'bottom-mid': '',
'bottom-left': '╰',
'bottom-right': '╯',
left: '│',
'left-mid': '├',
mid: '─',
'mid-mid': '',
right: '│',
'right-mid': '┤',
middle: '',
},
style: { 'padding-left': 1, 'padding-right': 1, head: [''], border: [''], compact: true },
},
noBorder: {
chars: {
top: '',
'top-mid': '',
'top-left': '',
'top-right': '',
bottom: '',
'bottom-mid': '',
'bottom-left': '',
'bottom-right': '',
left: '',
'left-mid': '',
mid: '',
'mid-mid': '',
right: '',
'right-mid': '',
middle: ' ',
},
style: { 'padding-left': 0, 'padding-right': 0 },
},
};
export default {
cliTableConfig,
};