@authress/login-react-native 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +466 -0
- package/package.json +63 -0
- package/src/authStorageManager.ts +142 -0
- package/src/httpClient.ts +165 -0
- package/src/index.ts +24 -0
- package/src/jwtManager.ts +101 -0
- package/src/loginClient.ts +371 -0
- package/src/settingsValidator.ts +55 -0
- package/src/types.ts +122 -0
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@authress/login-react-native",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Authress authentication SDK for React Native. Implements the full OAuth 2.0+ flow with native mobile storage and deep link handling for iOS and Android.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./src/index.ts",
|
|
14
|
+
"import": "./src/index.ts"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "node make.js",
|
|
19
|
+
"lint": "eslint src tests",
|
|
20
|
+
"test": "vitest"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react-native": ">=0.74.0",
|
|
24
|
+
"react-native-encrypted-storage": ">=4.0.0",
|
|
25
|
+
"react-native-nitro-cookies": ">=0.1.0",
|
|
26
|
+
"react-native-quick-crypto": ">=0.7.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@authress/eslint-config": "^2.0.18",
|
|
30
|
+
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
|
31
|
+
"@typescript-eslint/parser": "^8.49.0",
|
|
32
|
+
"eslint": "^9.39.1",
|
|
33
|
+
"react-native-nitro-cookies": "^1.1.0",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"typescript-eslint": "^8.49.0",
|
|
36
|
+
"fs-extra": "^11.3.4",
|
|
37
|
+
"vitest": "^4.0.15"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/Authress/authress-login-react-native"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"authentication",
|
|
45
|
+
"react-native",
|
|
46
|
+
"OAuth2",
|
|
47
|
+
"PKCE",
|
|
48
|
+
"Authress",
|
|
49
|
+
"login",
|
|
50
|
+
"iOS",
|
|
51
|
+
"Android"
|
|
52
|
+
],
|
|
53
|
+
"author": "Authress Developers <developers@authress.io> (https://authress.io)",
|
|
54
|
+
"license": "Apache-2.0",
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"eslint-plugin-import": "^2.32.0",
|
|
60
|
+
"js-base64": "^3.7.8",
|
|
61
|
+
"neverthrow": "^8.2.0"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { ResultAsync } from 'neverthrow';
|
|
2
|
+
import EncryptedStorage from 'react-native-encrypted-storage';
|
|
3
|
+
import NitroCookies from 'react-native-nitro-cookies';
|
|
4
|
+
|
|
5
|
+
const PENDING_AUTH_KEY = 'authress-pending-auth';
|
|
6
|
+
const COOKIES_BACKUP_KEY = 'authress-cookies';
|
|
7
|
+
|
|
8
|
+
export interface PendingAuthentication {
|
|
9
|
+
codeVerifier: string;
|
|
10
|
+
authenticationRequestId: string;
|
|
11
|
+
redirectUrl: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class SecurityContextError extends Error {
|
|
15
|
+
readonly code = 'SecurityContextError' as const;
|
|
16
|
+
constructor(message: string) { super(message); this.name = 'SecurityContextError'; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StoredCookie {
|
|
20
|
+
name: string;
|
|
21
|
+
value: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type RawCookieEntry = { name: string; value: string };
|
|
25
|
+
type RawCookieMap = Record<string, RawCookieEntry | RawCookieEntry[]>;
|
|
26
|
+
|
|
27
|
+
/** Multiple API calls may each set the same cookie name on different paths. NitroCookies may return an array for that name — pick the last value set. */
|
|
28
|
+
function lastValue(entry: RawCookieEntry | RawCookieEntry[] | undefined): string | null {
|
|
29
|
+
if (!entry) { return null; }
|
|
30
|
+
if (Array.isArray(entry)) { return entry[entry.length - 1]?.value ?? null; }
|
|
31
|
+
return entry.value ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Flatten a potentially-duplicated cookie map into a deduplicated name→value list, last value wins. */
|
|
35
|
+
function deduplicateCookies(cookies: RawCookieMap): StoredCookie[] {
|
|
36
|
+
const seen = new Map<string, string>();
|
|
37
|
+
for (const raw of Object.values(cookies)) {
|
|
38
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
if (e?.name) { seen.set(e.name, e.value); }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return [...seen.entries()].map(([name, value]) => ({ name, value }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class AuthStorageManager {
|
|
47
|
+
// ── PKCE state ──────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
setAuthenticationRequest(state?: PendingAuthentication | null): ResultAsync<void, SecurityContextError> {
|
|
50
|
+
return ResultAsync.fromPromise(
|
|
51
|
+
state
|
|
52
|
+
? EncryptedStorage.setItem(PENDING_AUTH_KEY, JSON.stringify(state))
|
|
53
|
+
: EncryptedStorage.removeItem(PENDING_AUTH_KEY),
|
|
54
|
+
e => new SecurityContextError(String(e))
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getAuthenticationRequest(): ResultAsync<PendingAuthentication | null, never> {
|
|
59
|
+
return ResultAsync.fromSafePromise(
|
|
60
|
+
EncryptedStorage.getItem(PENDING_AUTH_KEY)
|
|
61
|
+
.then(raw => raw ? JSON.parse(raw) as PendingAuthentication : null)
|
|
62
|
+
.catch(() => null)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── cookies ─────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
backupCookies(url: string): ResultAsync<void, never> {
|
|
69
|
+
return ResultAsync.fromSafePromise(
|
|
70
|
+
NitroCookies.get(url)
|
|
71
|
+
.then(async cookies => {
|
|
72
|
+
const deduplicated = deduplicateCookies(cookies as RawCookieMap);
|
|
73
|
+
if (!deduplicated.length) {return;}
|
|
74
|
+
await EncryptedStorage.setItem(COOKIES_BACKUP_KEY, JSON.stringify(deduplicated));
|
|
75
|
+
})
|
|
76
|
+
.catch(() => {})
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
restoreCookies(url: string): ResultAsync<void, never> {
|
|
81
|
+
return ResultAsync.fromSafePromise(
|
|
82
|
+
(async () => {
|
|
83
|
+
const existing = await NitroCookies.get(url);
|
|
84
|
+
if (Object.keys(existing).length) {return;}
|
|
85
|
+
|
|
86
|
+
const raw = await EncryptedStorage.getItem(COOKIES_BACKUP_KEY);
|
|
87
|
+
if (!raw) {return;}
|
|
88
|
+
|
|
89
|
+
const cookies: StoredCookie[] = JSON.parse(raw);
|
|
90
|
+
await Promise.all(
|
|
91
|
+
cookies.map(c => NitroCookies.set(url, { name: c.name, value: c.value, path: '/', httpOnly: true, secure: true }))
|
|
92
|
+
);
|
|
93
|
+
})().catch(() => {})
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getAuthorizationCookie(url: string): ResultAsync<string | null, never> {
|
|
98
|
+
return ResultAsync.fromSafePromise(
|
|
99
|
+
NitroCookies.get(url)
|
|
100
|
+
.then(cookies => lastValue((cookies as RawCookieMap).authorization))
|
|
101
|
+
.catch(() => null)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
getUserCookie(url: string): ResultAsync<string | null, never> {
|
|
106
|
+
return ResultAsync.fromSafePromise(
|
|
107
|
+
NitroCookies.get(url)
|
|
108
|
+
.then(cookies => lastValue((cookies as RawCookieMap).user))
|
|
109
|
+
.catch(() => null)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getCookieBackups(): ResultAsync<StoredCookie[] | null, never> {
|
|
114
|
+
return ResultAsync.fromSafePromise(
|
|
115
|
+
EncryptedStorage.getItem(COOKIES_BACKUP_KEY)
|
|
116
|
+
.then(raw => raw ? JSON.parse(raw) as StoredCookie[] : null)
|
|
117
|
+
.catch(() => null)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── clear ───────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/** Clears all native cookies for the given URL and removes all encrypted storage keys. */
|
|
124
|
+
clear(url: string): ResultAsync<void, never> {
|
|
125
|
+
return ResultAsync.fromSafePromise(
|
|
126
|
+
(async () => {
|
|
127
|
+
await EncryptedStorage.removeItem(COOKIES_BACKUP_KEY);
|
|
128
|
+
await EncryptedStorage.removeItem(PENDING_AUTH_KEY);
|
|
129
|
+
try {
|
|
130
|
+
const cookies = await NitroCookies.get(url);
|
|
131
|
+
await Promise.all(
|
|
132
|
+
Object.keys(cookies).map(name => NitroCookies.clearByName(url, name).catch(() => {}))
|
|
133
|
+
);
|
|
134
|
+
} catch (_) {
|
|
135
|
+
/* best effort */
|
|
136
|
+
}
|
|
137
|
+
})().catch(() => {})
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export default new AuthStorageManager();
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Result, ResultAsync, ok, err } from 'neverthrow';
|
|
2
|
+
import packageInfo from '../package.json' with { type: 'json' };
|
|
3
|
+
|
|
4
|
+
const defaultHeaders: Record<string, string> = {
|
|
5
|
+
'Content-Type': 'application/json',
|
|
6
|
+
'X-Powered-By': `Authress Login SDK; React Native; ${packageInfo.version}`
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export interface HttpResponse<T = unknown> {
|
|
10
|
+
url: string;
|
|
11
|
+
method: string;
|
|
12
|
+
status: number;
|
|
13
|
+
headers: Headers;
|
|
14
|
+
data: T;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Network or connectivity failure — the request never reached the Authress service. */
|
|
18
|
+
export interface AuthressHttpNetworkError {
|
|
19
|
+
name: 'AuthressHttpNetworkError';
|
|
20
|
+
url: string;
|
|
21
|
+
method: string;
|
|
22
|
+
data: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The Authress service responded with a 4xx status — the client sent a bad request. */
|
|
26
|
+
export interface AuthressHttpClientError {
|
|
27
|
+
name: 'AuthressHttpClientError';
|
|
28
|
+
url: string;
|
|
29
|
+
method: string;
|
|
30
|
+
status: number;
|
|
31
|
+
data: unknown;
|
|
32
|
+
headers: Headers;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The Authress service responded with a 5xx status — the service encountered an error. */
|
|
36
|
+
export interface AuthressHttpServiceError {
|
|
37
|
+
name: 'AuthressHttpServiceError';
|
|
38
|
+
url: string;
|
|
39
|
+
method: string;
|
|
40
|
+
status: number;
|
|
41
|
+
data: unknown;
|
|
42
|
+
headers: Headers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Any HTTP-level error — network failure, client error (4xx), or service error (5xx). */
|
|
46
|
+
export type AuthressHttpError = AuthressHttpNetworkError | AuthressHttpClientError | AuthressHttpServiceError;
|
|
47
|
+
|
|
48
|
+
export interface Logger {
|
|
49
|
+
debug(...args: unknown[]): void; // eslint-disable-line no-unused-vars
|
|
50
|
+
log(...args: unknown[]): void; // eslint-disable-line no-unused-vars
|
|
51
|
+
warn(...args: unknown[]): void; // eslint-disable-line no-unused-vars
|
|
52
|
+
error(...args: unknown[]): void; // eslint-disable-line no-unused-vars
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function retryExecutor<T>(func: () => Promise<Result<T, AuthressHttpError>>): ResultAsync<T, AuthressHttpError> {
|
|
56
|
+
return new ResultAsync(
|
|
57
|
+
(async (): Promise<Result<T, AuthressHttpError>> => {
|
|
58
|
+
let lastNetworkError: AuthressHttpNetworkError | null = null;
|
|
59
|
+
for (let iteration = 0; iteration < 5; iteration++) {
|
|
60
|
+
const result = await func();
|
|
61
|
+
if (result.isOk()) {
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (result.error.name !== 'AuthressHttpNetworkError') {
|
|
66
|
+
return result; // 4xx or 5xx — don't retry
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lastNetworkError = result.error;
|
|
70
|
+
await new Promise<void>(resolve => setTimeout(resolve, 10 * 2 ** iteration));
|
|
71
|
+
}
|
|
72
|
+
return err<AuthressHttpNetworkError>({ ...lastNetworkError!, name: 'AuthressHttpNetworkError', data: '[Authress Login SDK] Http Request failed due to a Network Error even after multiple retries' });
|
|
73
|
+
})()
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
class HttpClient {
|
|
78
|
+
private readonly loginUrl: string;
|
|
79
|
+
private readonly logger: Logger;
|
|
80
|
+
|
|
81
|
+
constructor(authressApiUrl: string, logger: Logger) {
|
|
82
|
+
if (!authressApiUrl) {
|
|
83
|
+
throw new Error('Custom Authress Domain Host is required');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.logger = logger;
|
|
87
|
+
const loginHostFullUrl = new URL(authressApiUrl);
|
|
88
|
+
this.loginUrl = `${loginHostFullUrl.origin}/api`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
get<T = unknown>(path: string, headers?: Record<string, string>, ignoreExpectedWarnings?: boolean): ResultAsync<HttpResponse<T>, AuthressHttpError> {
|
|
92
|
+
return retryExecutor(() => this.fetchWrapper<T>('GET', path, undefined, headers, ignoreExpectedWarnings));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
delete<T = unknown>(path: string, headers?: Record<string, string>, ignoreExpectedWarnings?: boolean): ResultAsync<HttpResponse<T>, AuthressHttpError> {
|
|
96
|
+
return retryExecutor(() => this.fetchWrapper<T>('DELETE', path, undefined, headers, ignoreExpectedWarnings));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
post<T = unknown>(path: string, data?: unknown, headers?: Record<string, string>, ignoreExpectedWarnings?: boolean): ResultAsync<HttpResponse<T>, AuthressHttpError> {
|
|
100
|
+
return retryExecutor(() => this.fetchWrapper<T>('POST', path, data, headers, ignoreExpectedWarnings));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
put<T = unknown>(path: string, data?: unknown, headers?: Record<string, string>, ignoreExpectedWarnings?: boolean): ResultAsync<HttpResponse<T>, AuthressHttpError> {
|
|
104
|
+
return retryExecutor(() => this.fetchWrapper<T>('PUT', path, data, headers, ignoreExpectedWarnings));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
patch<T = unknown>(path: string, data?: unknown, headers?: Record<string, string>, ignoreExpectedWarnings?: boolean): ResultAsync<HttpResponse<T>, AuthressHttpError> {
|
|
108
|
+
return retryExecutor(() => this.fetchWrapper<T>('PATCH', path, data, headers, ignoreExpectedWarnings));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async fetchWrapper<T = unknown>(rawMethod: string, path: string, data: unknown, requestHeaders?: Record<string, string>, ignoreExpectedWarnings?: boolean):
|
|
112
|
+
Promise<Result<HttpResponse<T>, AuthressHttpError>> {
|
|
113
|
+
const url = `${this.loginUrl}${path}`;
|
|
114
|
+
const method = rawMethod.toUpperCase();
|
|
115
|
+
const headers = Object.assign({}, defaultHeaders, requestHeaders);
|
|
116
|
+
|
|
117
|
+
this.logger.debug({ title: '[Authress Login SDK] HttpClient Request', method, url });
|
|
118
|
+
|
|
119
|
+
let response: Response;
|
|
120
|
+
try {
|
|
121
|
+
const init: RequestInit = { method, headers, credentials: 'include' };
|
|
122
|
+
if (data !== undefined) {
|
|
123
|
+
init.body = JSON.stringify(data);
|
|
124
|
+
}
|
|
125
|
+
response = await fetch(url, init);
|
|
126
|
+
} catch (error: unknown) {
|
|
127
|
+
return err<AuthressHttpNetworkError>({ name: 'AuthressHttpNetworkError', url, method, data: (error as Error)?.message ?? '' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
let resolvedError: unknown = {};
|
|
132
|
+
try {
|
|
133
|
+
resolvedError = await response.text();
|
|
134
|
+
resolvedError = JSON.parse(resolvedError as string);
|
|
135
|
+
} catch (_) {
|
|
136
|
+
/* non-JSON response — resolvedError remains the raw text string */
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const status = response.status;
|
|
140
|
+
let level: 'debug' | 'warn' = 'warn';
|
|
141
|
+
if (status === 401 || status === 404) {
|
|
142
|
+
level = 'debug';
|
|
143
|
+
} else if (status < 500 && ignoreExpectedWarnings) {
|
|
144
|
+
level = 'debug';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
this.logger[level]({ title: '[Authress Login SDK] HttpClient Response Error', method, url, status, data, headers, error: resolvedError });
|
|
148
|
+
return status >= 500
|
|
149
|
+
? err<AuthressHttpServiceError>({ name: 'AuthressHttpServiceError', url, method, status, data: resolvedError, headers: response.headers })
|
|
150
|
+
: err<AuthressHttpClientError>({ name: 'AuthressHttpClientError', url, method, status, data: resolvedError, headers: response.headers });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let responseBody: unknown = {};
|
|
154
|
+
try {
|
|
155
|
+
responseBody = await response.text();
|
|
156
|
+
responseBody = JSON.parse(responseBody as string);
|
|
157
|
+
} catch (_) {
|
|
158
|
+
/* non-JSON response — responseBody remains the raw text string */
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return ok({ url, method, status: response.status, headers: response.headers, data: responseBody as T });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export default HttpClient;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { LoginClient } from './loginClient.ts';
|
|
2
|
+
export type {
|
|
3
|
+
Settings,
|
|
4
|
+
AuthenticateResponse,
|
|
5
|
+
AuthenticationParameters,
|
|
6
|
+
LinkIdentityParameters,
|
|
7
|
+
UserIdentity,
|
|
8
|
+
UserProfile,
|
|
9
|
+
LinkedIdentity,
|
|
10
|
+
LinkedIdentityConnection,
|
|
11
|
+
TokenParameters,
|
|
12
|
+
Device,
|
|
13
|
+
AuthFlowError
|
|
14
|
+
} from './types.ts';
|
|
15
|
+
export {
|
|
16
|
+
TokenTimeoutError,
|
|
17
|
+
NoAuthenticationRequestInProgressError,
|
|
18
|
+
AuthenticationRequestMismatchError,
|
|
19
|
+
NotLoggedInError,
|
|
20
|
+
InvalidConnectionError
|
|
21
|
+
} from './types.ts';
|
|
22
|
+
export type { Logger, AuthressHttpNetworkError, AuthressHttpClientError, AuthressHttpServiceError, AuthressHttpError } from './httpClient.ts';
|
|
23
|
+
export { SecurityContextError } from './authStorageManager.ts';
|
|
24
|
+
export type { StoredCookie } from './authStorageManager.ts';
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { toBase64, fromBase64 } from 'js-base64';
|
|
2
|
+
import crypto from 'react-native-quick-crypto';
|
|
3
|
+
|
|
4
|
+
function b64urlEncode(input: string | ArrayBuffer | Uint8Array): string {
|
|
5
|
+
if (input instanceof ArrayBuffer || input instanceof Uint8Array) {
|
|
6
|
+
return toBase64(new Uint8Array(input instanceof ArrayBuffer ? input : input.buffer), true);
|
|
7
|
+
}
|
|
8
|
+
return toBase64(input, true);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function b64urlDecode(input: string): string {
|
|
12
|
+
return fromBase64(input);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface JwtPayload {
|
|
16
|
+
exp?: number;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DecodedJwt {
|
|
21
|
+
header: Record<string, unknown> | null;
|
|
22
|
+
payload: JwtPayload;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class JwtManager {
|
|
26
|
+
decode(token: string): JwtPayload | null {
|
|
27
|
+
if (!token) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return this.decodeFull(token)?.payload ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
decodeOrParse(token: string | object): JwtPayload | object | null {
|
|
34
|
+
if (!token) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
if (typeof token === 'object') {
|
|
38
|
+
return token;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(token);
|
|
42
|
+
} catch (_) {
|
|
43
|
+
return this.decode(token);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
decodeFull(token: string): DecodedJwt | null {
|
|
48
|
+
if (!token) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let header: Record<string, unknown> | null = null;
|
|
53
|
+
try {
|
|
54
|
+
header = JSON.parse(b64urlDecode(token.split('.')[0]));
|
|
55
|
+
} catch (_) {
|
|
56
|
+
// Header errors are non-fatal
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const payload: JwtPayload = JSON.parse(b64urlDecode(token.split('.')[1]));
|
|
61
|
+
if (payload.exp) {
|
|
62
|
+
payload.exp = payload.exp - 10;
|
|
63
|
+
}
|
|
64
|
+
return { header, payload };
|
|
65
|
+
} catch (_) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async getAuthCodes(): Promise<{ codeVerifier: string; codeChallenge: string }> {
|
|
71
|
+
const codeVerifier = b64urlEncode(crypto.getRandomValues(new Uint32Array(16)).toString());
|
|
72
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
|
|
73
|
+
const codeChallenge = b64urlEncode(hashBuffer);
|
|
74
|
+
return { codeVerifier, codeChallenge };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async calculateAntiAbuseHash(props: Record<string, unknown>): Promise<string> {
|
|
78
|
+
const timestamp = Date.now();
|
|
79
|
+
const valueString = Object.values(props)
|
|
80
|
+
.filter(v => v)
|
|
81
|
+
.map(v => {
|
|
82
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) {
|
|
83
|
+
return v;
|
|
84
|
+
}
|
|
85
|
+
return Object.keys(v as object).sort((a, b) => a.localeCompare(b)).map(key => (v as Record<string, unknown>)[key]).join('-');
|
|
86
|
+
})
|
|
87
|
+
.join('|');
|
|
88
|
+
|
|
89
|
+
let fineTuner = 0;
|
|
90
|
+
while (++fineTuner) {
|
|
91
|
+
const hash = b64urlEncode(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`${timestamp};${fineTuner};${valueString}`)));
|
|
92
|
+
if (hash.match(/^00/)) {
|
|
93
|
+
return `v2;${timestamp};${fineTuner};${hash}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error('HashFailed');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export default new JwtManager();
|