@geekapps/auth-react-native 0.1.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/README.md +108 -0
- package/dist/chunk-M525RFU3.js +231 -0
- package/dist/core.d.ts +79 -0
- package/dist/core.js +32 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +264 -0
- package/package.json +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# @geekapps/auth-react-native
|
|
2
|
+
|
|
3
|
+
SDK de login para apps React Native (Expo ou bare) que usam o Geekapps Auth (`app-api`) como Authorization Server.
|
|
4
|
+
|
|
5
|
+
Cobre:
|
|
6
|
+
|
|
7
|
+
- Login via Authorization Code + PKCE, abrindo o navegador do sistema (`Linking.openURL` por padrão, ou um `browserOpener` customizado — ex: `expo-web-browser`);
|
|
8
|
+
- Troca de `code` por tokens, com renovação automática (`refresh_token`) quando o access token está perto de expirar;
|
|
9
|
+
- Device Authorization Grant (útil pra fluxos de segundo dispositivo);
|
|
10
|
+
- Storage de tokens plugável — a lib não força nenhuma dependência nativa; em produção, injete um adapter sobre `expo-secure-store` ou `react-native-keychain`;
|
|
11
|
+
- `request()` — fetch autenticado contra o `app-api`, injeta o Bearer token e renova sozinho se necessário.
|
|
12
|
+
|
|
13
|
+
Núcleo (`core.ts`) é JS puro, sem imports de `react-native` — só `client.tsx` depende de `Linking`. `crypto.getRandomValues` precisa estar disponível no runtime (ver "Instalação").
|
|
14
|
+
|
|
15
|
+
## Instalação
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bun add @geekapps/auth-react-native
|
|
19
|
+
# polyfill de crypto.getRandomValues, necessário pro PKCE
|
|
20
|
+
bun add react-native-get-random-values
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No topo do `index.js`/`App.tsx` (antes de qualquer outro import):
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import "react-native-get-random-values";
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Uso
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { GeekappsAuthProvider, useAuth } from "@geekapps/auth-react-native";
|
|
33
|
+
|
|
34
|
+
export function App() {
|
|
35
|
+
return (
|
|
36
|
+
<GeekappsAuthProvider
|
|
37
|
+
config={{
|
|
38
|
+
issuer: "https://auth.suaapp.com",
|
|
39
|
+
clientId: "gk_client_xxx",
|
|
40
|
+
redirectUri: "myapp://callback", // precisa de pkceFlowEnabled=true na Application
|
|
41
|
+
scopes: ["openid", "profile", "email"],
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<Home />
|
|
45
|
+
</GeekappsAuthProvider>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function Home() {
|
|
50
|
+
const { isAuthenticated, isLoading, user, signIn, signOut, request } = useAuth();
|
|
51
|
+
|
|
52
|
+
if (isLoading) return null;
|
|
53
|
+
|
|
54
|
+
if (!isAuthenticated) {
|
|
55
|
+
return <Button title="Entrar" onPress={() => signIn()} />;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<View>
|
|
60
|
+
<Text>Olá, {user?.name as string}</Text>
|
|
61
|
+
<Button title="Sair" onPress={signOut} />
|
|
62
|
+
</View>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Storage seguro (recomendado em produção)
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import * as SecureStore from "expo-secure-store";
|
|
71
|
+
import type { TokenStorage } from "@geekapps/auth-react-native";
|
|
72
|
+
|
|
73
|
+
const secureStorage: TokenStorage = {
|
|
74
|
+
getItem: (key) => SecureStore.getItemAsync(key),
|
|
75
|
+
setItem: (key, value) => SecureStore.setItemAsync(key, value),
|
|
76
|
+
removeItem: (key) => SecureStore.deleteItemAsync(key),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
<GeekappsAuthProvider config={config} storage={secureStorage}>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Browser nativo em apps Expo
|
|
83
|
+
|
|
84
|
+
Por padrão o login abre no navegador externo (`Linking.openURL`). Em apps Expo, `expo-web-browser` dá uma UX melhor (Custom Tabs/ASWebAuthenticationSession, sem sair do app):
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
import * as WebBrowser from "expo-web-browser";
|
|
88
|
+
|
|
89
|
+
<GeekappsAuthProvider
|
|
90
|
+
config={config}
|
|
91
|
+
browserOpener={(url) => WebBrowser.openBrowserAsync(url).then(() => undefined)}
|
|
92
|
+
>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Device flow (segundo dispositivo)
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
const { startDeviceFlow, waitForDeviceAuthorization } = useAuth();
|
|
99
|
+
|
|
100
|
+
const device = await startDeviceFlow();
|
|
101
|
+
// mostra device.userCode e device.verificationUri pro usuário
|
|
102
|
+
await waitForDeviceAuthorization(device.deviceCode, device.interval);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Configuração necessária na Application (dashboard)
|
|
106
|
+
|
|
107
|
+
- `pkceFlowEnabled = true` — permite `redirect_uri` com custom scheme (ex: `myapp://callback`).
|
|
108
|
+
- Cadastrar `myapp://callback` na lista de `redirect_uris` da Application.
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
3
|
+
function resolveConfig(config) {
|
|
4
|
+
return {
|
|
5
|
+
issuer: config.issuer.replace(/\/$/, ""),
|
|
6
|
+
clientId: config.clientId,
|
|
7
|
+
redirectUri: config.redirectUri,
|
|
8
|
+
scopes: config.scopes ?? ["openid", "profile", "email"],
|
|
9
|
+
prompt: config.prompt
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
var GeekappsAuthError = class extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message) {
|
|
15
|
+
super(message ?? code);
|
|
16
|
+
this.name = "GeekappsAuthError";
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
21
|
+
function base64UrlEncode(bytes) {
|
|
22
|
+
let result = "";
|
|
23
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
24
|
+
const b0 = bytes[i];
|
|
25
|
+
const b1 = bytes[i + 1];
|
|
26
|
+
const b2 = bytes[i + 2];
|
|
27
|
+
result += BASE64_CHARS[b0 >> 2];
|
|
28
|
+
result += BASE64_CHARS[(b0 & 3) << 4 | (b1 === void 0 ? 0 : b1 >> 4)];
|
|
29
|
+
result += b1 === void 0 ? "" : BASE64_CHARS[(b1 & 15) << 2 | (b2 === void 0 ? 0 : b2 >> 6)];
|
|
30
|
+
result += b2 === void 0 ? "" : BASE64_CHARS[b2 & 63];
|
|
31
|
+
}
|
|
32
|
+
return result.replace(/\+/g, "-").replace(/\//g, "_");
|
|
33
|
+
}
|
|
34
|
+
function randomBytes(length) {
|
|
35
|
+
const bytes = new Uint8Array(length);
|
|
36
|
+
const cryptoObj = globalThis.crypto;
|
|
37
|
+
if (!cryptoObj?.getRandomValues) {
|
|
38
|
+
throw new GeekappsAuthError(
|
|
39
|
+
"invalid_response",
|
|
40
|
+
"crypto.getRandomValues indispon\xEDvel. Importe 'react-native-get-random-values' no topo do seu index/App.tsx antes de qualquer outro import."
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
cryptoObj.getRandomValues(bytes);
|
|
44
|
+
return bytes;
|
|
45
|
+
}
|
|
46
|
+
function generateCodeVerifier() {
|
|
47
|
+
return base64UrlEncode(randomBytes(32));
|
|
48
|
+
}
|
|
49
|
+
function generateCodeChallenge(verifier) {
|
|
50
|
+
const hash = sha256(new TextEncoder().encode(verifier));
|
|
51
|
+
return base64UrlEncode(hash);
|
|
52
|
+
}
|
|
53
|
+
function generateState() {
|
|
54
|
+
return base64UrlEncode(randomBytes(16));
|
|
55
|
+
}
|
|
56
|
+
function buildAuthorizationUrl(params) {
|
|
57
|
+
const { config, codeChallenge, state, prompt } = params;
|
|
58
|
+
const url = new URL(`${config.issuer}/authorize`);
|
|
59
|
+
url.searchParams.set("response_type", "code");
|
|
60
|
+
url.searchParams.set("client_id", config.clientId);
|
|
61
|
+
url.searchParams.set("redirect_uri", config.redirectUri);
|
|
62
|
+
url.searchParams.set("scope", config.scopes.join(" "));
|
|
63
|
+
url.searchParams.set("state", state);
|
|
64
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
65
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
66
|
+
const resolvedPrompt = prompt ?? config.prompt;
|
|
67
|
+
if (resolvedPrompt) url.searchParams.set("prompt", resolvedPrompt);
|
|
68
|
+
return url.toString();
|
|
69
|
+
}
|
|
70
|
+
function parseCallbackUrl(callbackUrl) {
|
|
71
|
+
const url = new URL(callbackUrl);
|
|
72
|
+
return {
|
|
73
|
+
code: url.searchParams.get("code") ?? void 0,
|
|
74
|
+
state: url.searchParams.get("state") ?? void 0,
|
|
75
|
+
error: url.searchParams.get("error") ?? void 0
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async function assertHttpSuccess(response) {
|
|
79
|
+
if (response.ok) return;
|
|
80
|
+
const data = await response.json().catch(() => null);
|
|
81
|
+
const message = data?.error_description ?? data?.error ?? `HTTP ${response.status}`;
|
|
82
|
+
throw new GeekappsAuthError("server_error", message);
|
|
83
|
+
}
|
|
84
|
+
function withObtainedAt(data) {
|
|
85
|
+
return {
|
|
86
|
+
accessToken: data.access_token,
|
|
87
|
+
idToken: data.id_token,
|
|
88
|
+
refreshToken: data.refresh_token,
|
|
89
|
+
tokenType: data.token_type ?? "Bearer",
|
|
90
|
+
expiresIn: data.expires_in ?? 3600,
|
|
91
|
+
scope: data.scope ?? "",
|
|
92
|
+
obtainedAt: Date.now()
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
async function exchangeCodeForToken(params) {
|
|
96
|
+
const { config, code, codeVerifier } = params;
|
|
97
|
+
const response = await fetch(`${config.issuer}/oauth/token`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
100
|
+
body: new URLSearchParams({
|
|
101
|
+
grant_type: "authorization_code",
|
|
102
|
+
code,
|
|
103
|
+
redirect_uri: config.redirectUri,
|
|
104
|
+
client_id: config.clientId,
|
|
105
|
+
code_verifier: codeVerifier
|
|
106
|
+
}).toString()
|
|
107
|
+
});
|
|
108
|
+
await assertHttpSuccess(response);
|
|
109
|
+
return withObtainedAt(await response.json());
|
|
110
|
+
}
|
|
111
|
+
async function refreshAccessToken(params) {
|
|
112
|
+
const { config, refreshToken } = params;
|
|
113
|
+
const response = await fetch(`${config.issuer}/oauth/token`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
116
|
+
body: new URLSearchParams({
|
|
117
|
+
grant_type: "refresh_token",
|
|
118
|
+
refresh_token: refreshToken,
|
|
119
|
+
client_id: config.clientId
|
|
120
|
+
}).toString()
|
|
121
|
+
});
|
|
122
|
+
await assertHttpSuccess(response);
|
|
123
|
+
return withObtainedAt(await response.json());
|
|
124
|
+
}
|
|
125
|
+
async function startDeviceAuthorization(params) {
|
|
126
|
+
const { config } = params;
|
|
127
|
+
const response = await fetch(`${config.issuer}/oauth/device/authorize`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
130
|
+
body: new URLSearchParams({
|
|
131
|
+
client_id: config.clientId,
|
|
132
|
+
scope: config.scopes.join(" ")
|
|
133
|
+
}).toString()
|
|
134
|
+
});
|
|
135
|
+
await assertHttpSuccess(response);
|
|
136
|
+
const data = await response.json();
|
|
137
|
+
return {
|
|
138
|
+
deviceCode: data.device_code,
|
|
139
|
+
userCode: data.user_code,
|
|
140
|
+
verificationUri: data.verification_uri,
|
|
141
|
+
verificationUriComplete: data.verification_uri_complete,
|
|
142
|
+
expiresIn: data.expires_in,
|
|
143
|
+
interval: data.interval
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async function requestDeviceToken(params) {
|
|
147
|
+
const { config, deviceCode } = params;
|
|
148
|
+
const response = await fetch(`${config.issuer}/oauth/device/token`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
151
|
+
body: new URLSearchParams({
|
|
152
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
153
|
+
client_id: config.clientId,
|
|
154
|
+
device_code: deviceCode
|
|
155
|
+
}).toString()
|
|
156
|
+
});
|
|
157
|
+
if (response.status === 400) {
|
|
158
|
+
const data = await response.json().catch(() => null);
|
|
159
|
+
switch (data?.error) {
|
|
160
|
+
case "authorization_pending":
|
|
161
|
+
throw new GeekappsAuthError("authorization_pending");
|
|
162
|
+
case "slow_down":
|
|
163
|
+
throw new GeekappsAuthError("slow_down");
|
|
164
|
+
case "access_denied":
|
|
165
|
+
throw new GeekappsAuthError("access_denied");
|
|
166
|
+
case "expired_token":
|
|
167
|
+
throw new GeekappsAuthError("expired_token");
|
|
168
|
+
default:
|
|
169
|
+
throw new GeekappsAuthError("invalid_grant", data?.error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await assertHttpSuccess(response);
|
|
173
|
+
return withObtainedAt(await response.json());
|
|
174
|
+
}
|
|
175
|
+
async function pollDeviceToken(params) {
|
|
176
|
+
const { config, deviceCode, timeoutMs = 3e5, signal } = params;
|
|
177
|
+
let currentInterval = params.interval;
|
|
178
|
+
const startedAt = Date.now();
|
|
179
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
180
|
+
if (signal?.aborted) throw new GeekappsAuthError("cancelled");
|
|
181
|
+
await new Promise((resolve) => setTimeout(resolve, currentInterval * 1e3));
|
|
182
|
+
try {
|
|
183
|
+
return await requestDeviceToken({ config, deviceCode });
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (!(error instanceof GeekappsAuthError)) throw error;
|
|
186
|
+
if (error.code === "authorization_pending") continue;
|
|
187
|
+
if (error.code === "slow_down") {
|
|
188
|
+
currentInterval += 5;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
throw new GeekappsAuthError("expired_token");
|
|
195
|
+
}
|
|
196
|
+
function isTokenExpiringSoon(tokens, skewSeconds = 60) {
|
|
197
|
+
const expiresAt = tokens.obtainedAt + tokens.expiresIn * 1e3;
|
|
198
|
+
return Date.now() >= expiresAt - skewSeconds * 1e3;
|
|
199
|
+
}
|
|
200
|
+
async function fetchUserInfo(params) {
|
|
201
|
+
const { config, accessToken } = params;
|
|
202
|
+
const response = await fetch(`${config.issuer}/me`, {
|
|
203
|
+
headers: { authorization: `Bearer ${accessToken}` }
|
|
204
|
+
});
|
|
205
|
+
await assertHttpSuccess(response);
|
|
206
|
+
return response.json();
|
|
207
|
+
}
|
|
208
|
+
async function revokeSession(params) {
|
|
209
|
+
const { config, accessToken } = params;
|
|
210
|
+
await fetch(`${config.issuer}/auth/sign-out`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: { authorization: `Bearer ${accessToken}` }
|
|
213
|
+
}).catch(() => null);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export {
|
|
217
|
+
resolveConfig,
|
|
218
|
+
GeekappsAuthError,
|
|
219
|
+
generateCodeVerifier,
|
|
220
|
+
generateCodeChallenge,
|
|
221
|
+
generateState,
|
|
222
|
+
buildAuthorizationUrl,
|
|
223
|
+
parseCallbackUrl,
|
|
224
|
+
exchangeCodeForToken,
|
|
225
|
+
refreshAccessToken,
|
|
226
|
+
startDeviceAuthorization,
|
|
227
|
+
pollDeviceToken,
|
|
228
|
+
isTokenExpiringSoon,
|
|
229
|
+
fetchUserInfo,
|
|
230
|
+
revokeSession
|
|
231
|
+
};
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
type PromptMode = "login" | "select_account";
|
|
2
|
+
type GeekappsAuthConfig = {
|
|
3
|
+
/** Auth API/issuer URL (app-api). Ex: https://auth.suaapp.com */
|
|
4
|
+
issuer: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
/** Deve bater com um redirect_uri de custom scheme cadastrado na Application (pkceFlowEnabled). Ex: myapp://callback */
|
|
7
|
+
redirectUri: string;
|
|
8
|
+
scopes?: string[];
|
|
9
|
+
prompt?: PromptMode;
|
|
10
|
+
};
|
|
11
|
+
type ResolvedGeekappsAuthConfig = Required<Omit<GeekappsAuthConfig, "prompt">> & Pick<GeekappsAuthConfig, "prompt">;
|
|
12
|
+
declare function resolveConfig(config: GeekappsAuthConfig): ResolvedGeekappsAuthConfig;
|
|
13
|
+
type TokenResponse = {
|
|
14
|
+
accessToken: string;
|
|
15
|
+
idToken?: string;
|
|
16
|
+
refreshToken?: string;
|
|
17
|
+
tokenType: string;
|
|
18
|
+
expiresIn: number;
|
|
19
|
+
scope: string;
|
|
20
|
+
/** epoch ms, calculado no momento do parse — usado pra saber quando renovar. */
|
|
21
|
+
obtainedAt: number;
|
|
22
|
+
};
|
|
23
|
+
type DeviceAuthorizationResponse = {
|
|
24
|
+
deviceCode: string;
|
|
25
|
+
userCode: string;
|
|
26
|
+
verificationUri: string;
|
|
27
|
+
verificationUriComplete?: string;
|
|
28
|
+
expiresIn: number;
|
|
29
|
+
interval: number;
|
|
30
|
+
};
|
|
31
|
+
type GeekappsAuthErrorCode = "authorization_pending" | "slow_down" | "access_denied" | "expired_token" | "invalid_grant" | "network_error" | "server_error" | "invalid_response" | "cancelled";
|
|
32
|
+
declare class GeekappsAuthError extends Error {
|
|
33
|
+
code: GeekappsAuthErrorCode;
|
|
34
|
+
constructor(code: GeekappsAuthErrorCode, message?: string);
|
|
35
|
+
}
|
|
36
|
+
declare function generateCodeVerifier(): string;
|
|
37
|
+
declare function generateCodeChallenge(verifier: string): string;
|
|
38
|
+
declare function generateState(): string;
|
|
39
|
+
declare function buildAuthorizationUrl(params: {
|
|
40
|
+
config: ResolvedGeekappsAuthConfig;
|
|
41
|
+
codeChallenge: string;
|
|
42
|
+
state: string;
|
|
43
|
+
prompt?: PromptMode;
|
|
44
|
+
}): string;
|
|
45
|
+
declare function parseCallbackUrl(callbackUrl: string): {
|
|
46
|
+
code?: string;
|
|
47
|
+
state?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
};
|
|
50
|
+
declare function exchangeCodeForToken(params: {
|
|
51
|
+
config: ResolvedGeekappsAuthConfig;
|
|
52
|
+
code: string;
|
|
53
|
+
codeVerifier: string;
|
|
54
|
+
}): Promise<TokenResponse>;
|
|
55
|
+
declare function refreshAccessToken(params: {
|
|
56
|
+
config: ResolvedGeekappsAuthConfig;
|
|
57
|
+
refreshToken: string;
|
|
58
|
+
}): Promise<TokenResponse>;
|
|
59
|
+
declare function startDeviceAuthorization(params: {
|
|
60
|
+
config: ResolvedGeekappsAuthConfig;
|
|
61
|
+
}): Promise<DeviceAuthorizationResponse>;
|
|
62
|
+
declare function pollDeviceToken(params: {
|
|
63
|
+
config: ResolvedGeekappsAuthConfig;
|
|
64
|
+
deviceCode: string;
|
|
65
|
+
interval: number;
|
|
66
|
+
timeoutMs?: number;
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
}): Promise<TokenResponse>;
|
|
69
|
+
declare function isTokenExpiringSoon(tokens: TokenResponse, skewSeconds?: number): boolean;
|
|
70
|
+
declare function fetchUserInfo(params: {
|
|
71
|
+
config: ResolvedGeekappsAuthConfig;
|
|
72
|
+
accessToken: string;
|
|
73
|
+
}): Promise<any>;
|
|
74
|
+
declare function revokeSession(params: {
|
|
75
|
+
config: ResolvedGeekappsAuthConfig;
|
|
76
|
+
accessToken: string;
|
|
77
|
+
}): Promise<void>;
|
|
78
|
+
|
|
79
|
+
export { type DeviceAuthorizationResponse, type GeekappsAuthConfig, GeekappsAuthError, type GeekappsAuthErrorCode, type PromptMode, type ResolvedGeekappsAuthConfig, type TokenResponse, buildAuthorizationUrl, exchangeCodeForToken, fetchUserInfo, generateCodeChallenge, generateCodeVerifier, generateState, isTokenExpiringSoon, parseCallbackUrl, pollDeviceToken, refreshAccessToken, resolveConfig, revokeSession, startDeviceAuthorization };
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GeekappsAuthError,
|
|
3
|
+
buildAuthorizationUrl,
|
|
4
|
+
exchangeCodeForToken,
|
|
5
|
+
fetchUserInfo,
|
|
6
|
+
generateCodeChallenge,
|
|
7
|
+
generateCodeVerifier,
|
|
8
|
+
generateState,
|
|
9
|
+
isTokenExpiringSoon,
|
|
10
|
+
parseCallbackUrl,
|
|
11
|
+
pollDeviceToken,
|
|
12
|
+
refreshAccessToken,
|
|
13
|
+
resolveConfig,
|
|
14
|
+
revokeSession,
|
|
15
|
+
startDeviceAuthorization
|
|
16
|
+
} from "./chunk-M525RFU3.js";
|
|
17
|
+
export {
|
|
18
|
+
GeekappsAuthError,
|
|
19
|
+
buildAuthorizationUrl,
|
|
20
|
+
exchangeCodeForToken,
|
|
21
|
+
fetchUserInfo,
|
|
22
|
+
generateCodeChallenge,
|
|
23
|
+
generateCodeVerifier,
|
|
24
|
+
generateState,
|
|
25
|
+
isTokenExpiringSoon,
|
|
26
|
+
parseCallbackUrl,
|
|
27
|
+
pollDeviceToken,
|
|
28
|
+
refreshAccessToken,
|
|
29
|
+
resolveConfig,
|
|
30
|
+
revokeSession,
|
|
31
|
+
startDeviceAuthorization
|
|
32
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { TokenResponse, GeekappsAuthError, PromptMode, DeviceAuthorizationResponse, GeekappsAuthConfig } from './core.js';
|
|
2
|
+
export { GeekappsAuthErrorCode, ResolvedGeekappsAuthConfig, buildAuthorizationUrl, exchangeCodeForToken, fetchUserInfo, generateCodeChallenge, generateCodeVerifier, generateState, isTokenExpiringSoon, parseCallbackUrl, pollDeviceToken, refreshAccessToken, resolveConfig, revokeSession, startDeviceAuthorization } from './core.js';
|
|
3
|
+
import { ReactNode, ReactElement } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Storage plugável — a lib não força nenhuma dependência nativa de storage.
|
|
7
|
+
* Em produção, passe um adapter sobre `expo-secure-store` ou `react-native-keychain`
|
|
8
|
+
* (guarda credenciais fora do AsyncStorage, que não é seguro pra tokens).
|
|
9
|
+
* Sem `storage` configurado, os tokens ficam só em memória (perdidos ao fechar o app).
|
|
10
|
+
*/
|
|
11
|
+
type TokenStorage = {
|
|
12
|
+
getItem(key: string): Promise<string | null>;
|
|
13
|
+
setItem(key: string, value: string): Promise<void>;
|
|
14
|
+
removeItem(key: string): Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
declare function createInMemoryStorage(): TokenStorage;
|
|
17
|
+
declare function loadStoredTokens(storage: TokenStorage): Promise<TokenResponse | null>;
|
|
18
|
+
declare function saveStoredTokens(storage: TokenStorage, tokens: TokenResponse): Promise<void>;
|
|
19
|
+
declare function clearStoredTokens(storage: TokenStorage): Promise<void>;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Abre a URL de autorização no navegador do sistema. Por padrão usa `Linking.openURL`
|
|
23
|
+
* (funciona em qualquer RN, bare ou Expo). Se o app usa Expo, pode passar
|
|
24
|
+
* `browserOpener={(url) => WebBrowser.openBrowserAsync(url)}` (de `expo-web-browser`)
|
|
25
|
+
* pra ter Custom Tabs/ASWebAuthenticationSession nativo em vez do browser externo.
|
|
26
|
+
*/
|
|
27
|
+
type BrowserOpener = (url: string) => Promise<void> | void;
|
|
28
|
+
type GeekappsAuthProviderProps = {
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
config: GeekappsAuthConfig;
|
|
31
|
+
storage?: TokenStorage;
|
|
32
|
+
browserOpener?: BrowserOpener;
|
|
33
|
+
};
|
|
34
|
+
type SignInOptions = {
|
|
35
|
+
prompt?: PromptMode;
|
|
36
|
+
};
|
|
37
|
+
type GeekappsAuthContextValue = {
|
|
38
|
+
isAuthenticated: boolean;
|
|
39
|
+
isLoading: boolean;
|
|
40
|
+
tokens: TokenResponse | null;
|
|
41
|
+
user: Record<string, unknown> | null;
|
|
42
|
+
error: GeekappsAuthError | null;
|
|
43
|
+
signIn: (options?: SignInOptions) => Promise<void>;
|
|
44
|
+
signOut: () => Promise<void>;
|
|
45
|
+
refreshUser: () => Promise<void>;
|
|
46
|
+
getAccessToken: () => Promise<string | null>;
|
|
47
|
+
startDeviceFlow: () => Promise<DeviceAuthorizationResponse>;
|
|
48
|
+
waitForDeviceAuthorization: (deviceCode: string, interval: number) => Promise<void>;
|
|
49
|
+
/** fetch autenticado contra o app-api, injeta Bearer e renova o token se necessário. */
|
|
50
|
+
request: <T>(path: string, init?: RequestInit) => Promise<T>;
|
|
51
|
+
};
|
|
52
|
+
declare function GeekappsAuthProvider({ children, config: rawConfig, storage, browserOpener, }: GeekappsAuthProviderProps): ReactElement;
|
|
53
|
+
declare function useAuth(): GeekappsAuthContextValue;
|
|
54
|
+
|
|
55
|
+
export { type BrowserOpener, DeviceAuthorizationResponse, GeekappsAuthConfig, type GeekappsAuthContextValue, GeekappsAuthError, GeekappsAuthProvider, type GeekappsAuthProviderProps, PromptMode, type SignInOptions, TokenResponse, type TokenStorage, clearStoredTokens, createInMemoryStorage, loadStoredTokens, saveStoredTokens, useAuth };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GeekappsAuthError,
|
|
3
|
+
buildAuthorizationUrl,
|
|
4
|
+
exchangeCodeForToken,
|
|
5
|
+
fetchUserInfo,
|
|
6
|
+
generateCodeChallenge,
|
|
7
|
+
generateCodeVerifier,
|
|
8
|
+
generateState,
|
|
9
|
+
isTokenExpiringSoon,
|
|
10
|
+
parseCallbackUrl,
|
|
11
|
+
pollDeviceToken,
|
|
12
|
+
refreshAccessToken,
|
|
13
|
+
resolveConfig,
|
|
14
|
+
revokeSession,
|
|
15
|
+
startDeviceAuthorization
|
|
16
|
+
} from "./chunk-M525RFU3.js";
|
|
17
|
+
|
|
18
|
+
// src/storage.ts
|
|
19
|
+
function createInMemoryStorage() {
|
|
20
|
+
const store = /* @__PURE__ */ new Map();
|
|
21
|
+
return {
|
|
22
|
+
async getItem(key) {
|
|
23
|
+
return store.get(key) ?? null;
|
|
24
|
+
},
|
|
25
|
+
async setItem(key, value) {
|
|
26
|
+
store.set(key, value);
|
|
27
|
+
},
|
|
28
|
+
async removeItem(key) {
|
|
29
|
+
store.delete(key);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
var TOKENS_KEY = "geekapps_auth_tokens";
|
|
34
|
+
async function loadStoredTokens(storage) {
|
|
35
|
+
const raw = await storage.getItem(TOKENS_KEY);
|
|
36
|
+
if (!raw) return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function saveStoredTokens(storage, tokens) {
|
|
44
|
+
await storage.setItem(TOKENS_KEY, JSON.stringify(tokens));
|
|
45
|
+
}
|
|
46
|
+
async function clearStoredTokens(storage) {
|
|
47
|
+
await storage.removeItem(TOKENS_KEY);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/client.tsx
|
|
51
|
+
import {
|
|
52
|
+
createContext,
|
|
53
|
+
useCallback,
|
|
54
|
+
useContext,
|
|
55
|
+
useEffect,
|
|
56
|
+
useMemo,
|
|
57
|
+
useRef,
|
|
58
|
+
useState
|
|
59
|
+
} from "react";
|
|
60
|
+
import { Linking } from "react-native";
|
|
61
|
+
import { jsx } from "react/jsx-runtime";
|
|
62
|
+
var GeekappsAuthContext = createContext(null);
|
|
63
|
+
function GeekappsAuthProvider({
|
|
64
|
+
children,
|
|
65
|
+
config: rawConfig,
|
|
66
|
+
storage,
|
|
67
|
+
browserOpener
|
|
68
|
+
}) {
|
|
69
|
+
const config = useMemo(() => resolveConfig(rawConfig), [rawConfig]);
|
|
70
|
+
const resolvedStorage = useMemo(() => storage ?? createInMemoryStorage(), [storage]);
|
|
71
|
+
const openBrowser = browserOpener ?? ((url) => Linking.openURL(url));
|
|
72
|
+
const [tokens, setTokens] = useState(null);
|
|
73
|
+
const [user, setUser] = useState(null);
|
|
74
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
75
|
+
const [error, setError] = useState(null);
|
|
76
|
+
const pendingFlow = useRef(null);
|
|
77
|
+
const refreshingPromise = useRef(null);
|
|
78
|
+
const persistTokens = useCallback(
|
|
79
|
+
async (next) => {
|
|
80
|
+
setTokens(next);
|
|
81
|
+
if (next) await saveStoredTokens(resolvedStorage, next);
|
|
82
|
+
else await clearStoredTokens(resolvedStorage);
|
|
83
|
+
},
|
|
84
|
+
[resolvedStorage]
|
|
85
|
+
);
|
|
86
|
+
const refreshUser = useCallback(async () => {
|
|
87
|
+
if (!tokens) {
|
|
88
|
+
setUser(null);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const info = await fetchUserInfo({ config, accessToken: tokens.accessToken });
|
|
93
|
+
setUser(info);
|
|
94
|
+
} catch {
|
|
95
|
+
setUser(null);
|
|
96
|
+
}
|
|
97
|
+
}, [config, tokens]);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
(async () => {
|
|
100
|
+
const stored = await loadStoredTokens(resolvedStorage);
|
|
101
|
+
setTokens(stored);
|
|
102
|
+
setIsLoading(false);
|
|
103
|
+
})();
|
|
104
|
+
}, [resolvedStorage]);
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
void refreshUser();
|
|
107
|
+
}, [refreshUser]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
function handleUrl(url) {
|
|
110
|
+
const flow = pendingFlow.current;
|
|
111
|
+
if (!flow || !url.startsWith(config.redirectUri)) return;
|
|
112
|
+
const { code, state, error: callbackError } = parseCallbackUrl(url);
|
|
113
|
+
pendingFlow.current = null;
|
|
114
|
+
if (callbackError) {
|
|
115
|
+
flow.reject(new GeekappsAuthError("access_denied", callbackError));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!code || state !== flow.state) {
|
|
119
|
+
flow.reject(new GeekappsAuthError("invalid_response", "state ou code ausente/inv\xE1lido"));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
exchangeCodeForToken({ config, code, codeVerifier: flow.codeVerifier }).then(async (result) => {
|
|
123
|
+
await persistTokens(result);
|
|
124
|
+
flow.resolve();
|
|
125
|
+
}).catch(flow.reject);
|
|
126
|
+
}
|
|
127
|
+
const subscription = Linking.addEventListener("url", ({ url }) => handleUrl(url));
|
|
128
|
+
Linking.getInitialURL().then((url) => {
|
|
129
|
+
if (url) handleUrl(url);
|
|
130
|
+
});
|
|
131
|
+
return () => subscription.remove();
|
|
132
|
+
}, [config, persistTokens]);
|
|
133
|
+
const signIn = useCallback(
|
|
134
|
+
async (options) => {
|
|
135
|
+
setError(null);
|
|
136
|
+
setIsLoading(true);
|
|
137
|
+
try {
|
|
138
|
+
const codeVerifier = generateCodeVerifier();
|
|
139
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
140
|
+
const state = generateState();
|
|
141
|
+
const authUrl = buildAuthorizationUrl({
|
|
142
|
+
config,
|
|
143
|
+
codeChallenge,
|
|
144
|
+
state,
|
|
145
|
+
prompt: options?.prompt
|
|
146
|
+
});
|
|
147
|
+
await new Promise((resolve, reject) => {
|
|
148
|
+
pendingFlow.current = { state, codeVerifier, resolve, reject };
|
|
149
|
+
void openBrowser(authUrl);
|
|
150
|
+
});
|
|
151
|
+
await refreshUser();
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const authError = err instanceof GeekappsAuthError ? err : new GeekappsAuthError("network_error", String(err));
|
|
154
|
+
setError(authError);
|
|
155
|
+
throw authError;
|
|
156
|
+
} finally {
|
|
157
|
+
setIsLoading(false);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
[config, openBrowser, refreshUser]
|
|
161
|
+
);
|
|
162
|
+
const signOut = useCallback(async () => {
|
|
163
|
+
if (tokens) await revokeSession({ config, accessToken: tokens.accessToken });
|
|
164
|
+
await persistTokens(null);
|
|
165
|
+
setUser(null);
|
|
166
|
+
}, [config, persistTokens, tokens]);
|
|
167
|
+
const startDeviceFlow = useCallback(() => startDeviceAuthorization({ config }), [config]);
|
|
168
|
+
const waitForDeviceAuthorization = useCallback(
|
|
169
|
+
async (deviceCode, interval) => {
|
|
170
|
+
const result = await pollDeviceToken({ config, deviceCode, interval });
|
|
171
|
+
await persistTokens(result);
|
|
172
|
+
await refreshUser();
|
|
173
|
+
},
|
|
174
|
+
[config, persistTokens, refreshUser]
|
|
175
|
+
);
|
|
176
|
+
const ensureFreshTokens = useCallback(async () => {
|
|
177
|
+
if (!tokens) return null;
|
|
178
|
+
if (!isTokenExpiringSoon(tokens)) return tokens;
|
|
179
|
+
if (!tokens.refreshToken) return tokens;
|
|
180
|
+
if (!refreshingPromise.current) {
|
|
181
|
+
refreshingPromise.current = refreshAccessToken({ config, refreshToken: tokens.refreshToken }).then(async (next) => {
|
|
182
|
+
await persistTokens(next);
|
|
183
|
+
return next;
|
|
184
|
+
}).finally(() => {
|
|
185
|
+
refreshingPromise.current = null;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
return await refreshingPromise.current;
|
|
190
|
+
} catch {
|
|
191
|
+
await persistTokens(null);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}, [config, persistTokens, tokens]);
|
|
195
|
+
const getAccessToken = useCallback(async () => {
|
|
196
|
+
const fresh = await ensureFreshTokens();
|
|
197
|
+
return fresh?.accessToken ?? null;
|
|
198
|
+
}, [ensureFreshTokens]);
|
|
199
|
+
const request = useCallback(
|
|
200
|
+
async (path, init) => {
|
|
201
|
+
const accessToken = await getAccessToken();
|
|
202
|
+
const response = await fetch(`${config.issuer}${path}`, {
|
|
203
|
+
...init,
|
|
204
|
+
headers: {
|
|
205
|
+
...init?.body ? { "content-type": "application/json" } : {},
|
|
206
|
+
...init?.headers,
|
|
207
|
+
...accessToken ? { authorization: `Bearer ${accessToken}` } : {}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
const data = await response.json().catch(() => null);
|
|
212
|
+
throw new GeekappsAuthError("server_error", data?.error_description ?? data?.error ?? `HTTP ${response.status}`);
|
|
213
|
+
}
|
|
214
|
+
if (response.status === 204) return null;
|
|
215
|
+
return response.json();
|
|
216
|
+
},
|
|
217
|
+
[config, getAccessToken]
|
|
218
|
+
);
|
|
219
|
+
const value = useMemo(
|
|
220
|
+
() => ({
|
|
221
|
+
isAuthenticated: tokens !== null,
|
|
222
|
+
isLoading,
|
|
223
|
+
tokens,
|
|
224
|
+
user,
|
|
225
|
+
error,
|
|
226
|
+
signIn,
|
|
227
|
+
signOut,
|
|
228
|
+
refreshUser,
|
|
229
|
+
getAccessToken,
|
|
230
|
+
startDeviceFlow,
|
|
231
|
+
waitForDeviceAuthorization,
|
|
232
|
+
request
|
|
233
|
+
}),
|
|
234
|
+
[error, getAccessToken, isLoading, refreshUser, request, signIn, signOut, startDeviceFlow, tokens, user, waitForDeviceAuthorization]
|
|
235
|
+
);
|
|
236
|
+
return /* @__PURE__ */ jsx(GeekappsAuthContext.Provider, { value, children });
|
|
237
|
+
}
|
|
238
|
+
function useAuth() {
|
|
239
|
+
const context = useContext(GeekappsAuthContext);
|
|
240
|
+
if (!context) throw new Error("useAuth must be used inside GeekappsAuthProvider");
|
|
241
|
+
return context;
|
|
242
|
+
}
|
|
243
|
+
export {
|
|
244
|
+
GeekappsAuthError,
|
|
245
|
+
GeekappsAuthProvider,
|
|
246
|
+
buildAuthorizationUrl,
|
|
247
|
+
clearStoredTokens,
|
|
248
|
+
createInMemoryStorage,
|
|
249
|
+
exchangeCodeForToken,
|
|
250
|
+
fetchUserInfo,
|
|
251
|
+
generateCodeChallenge,
|
|
252
|
+
generateCodeVerifier,
|
|
253
|
+
generateState,
|
|
254
|
+
isTokenExpiringSoon,
|
|
255
|
+
loadStoredTokens,
|
|
256
|
+
parseCallbackUrl,
|
|
257
|
+
pollDeviceToken,
|
|
258
|
+
refreshAccessToken,
|
|
259
|
+
resolveConfig,
|
|
260
|
+
revokeSession,
|
|
261
|
+
saveStoredTokens,
|
|
262
|
+
startDeviceAuthorization,
|
|
263
|
+
useAuth
|
|
264
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@geekapps/auth-react-native",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Geekapps Auth SDK for React Native (Expo and bare) apps — PKCE login, device flow, token refresh.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./core": {
|
|
15
|
+
"types": "./dist/core.d.ts",
|
|
16
|
+
"import": "./dist/core.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsup src/index.ts src/core.ts --format esm --dts --clean",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@noble/hashes": "^1.6.1"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": ">=18",
|
|
32
|
+
"react-native": ">=0.73"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/react": "^19.0.0",
|
|
36
|
+
"react": "^19.0.0",
|
|
37
|
+
"react-native": "^0.76.0",
|
|
38
|
+
"tsup": "^8.3.5",
|
|
39
|
+
"typescript": "^5.7.2"
|
|
40
|
+
}
|
|
41
|
+
}
|