@pimia/sdk 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/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/api.d.ts +13463 -0
- package/dist/api.js +5 -0
- package/dist/client.d.ts +96 -0
- package/dist/client.js +232 -0
- package/dist/errors.d.ts +57 -0
- package/dist/errors.js +109 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +33 -0
- package/dist/oauth.d.ts +83 -0
- package/dist/oauth.js +147 -0
- package/dist/tokens.d.ts +46 -0
- package/dist/tokens.js +46 -0
- package/package.json +58 -0
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ceremonia OAuth 2.0 contra el Authorization Server de Pimia.
|
|
3
|
+
*
|
|
4
|
+
* Un tenant = un servidor de autorización (`https://{tenant}.pimia.es`), y un
|
|
5
|
+
* token vale solo para ese tenant (modelo un-token-por-tienda). Todo lo que
|
|
6
|
+
* hay aquí es de la parte «servidor» de tu app salvo `createPkceChallenge` y
|
|
7
|
+
* `buildAuthorizeUrl`, que también valen en el navegador.
|
|
8
|
+
*/
|
|
9
|
+
import { OAuthError } from './errors.js';
|
|
10
|
+
import { tokenSetFromResponse } from './tokens.js';
|
|
11
|
+
export class OAuth {
|
|
12
|
+
config;
|
|
13
|
+
baseUrl;
|
|
14
|
+
doFetch;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, '');
|
|
18
|
+
this.doFetch = config.fetch ?? globalThis.fetch;
|
|
19
|
+
}
|
|
20
|
+
/** `GET /.well-known/oauth-authorization-server` — útil para no cablear rutas. */
|
|
21
|
+
async metadata() {
|
|
22
|
+
const response = await this.doFetch(`${this.baseUrl}/.well-known/oauth-authorization-server`, {
|
|
23
|
+
headers: { accept: 'application/json' },
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new OAuthError('metadata_unavailable', `HTTP ${response.status}`);
|
|
27
|
+
}
|
|
28
|
+
return (await response.json());
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* URL a la que mandas al usuario. Verás la pantalla de consentimiento de
|
|
32
|
+
* Pimia con los permisos de los scopes que pidas.
|
|
33
|
+
*/
|
|
34
|
+
buildAuthorizeUrl({ scopes, state, pkce }) {
|
|
35
|
+
const query = new URLSearchParams({
|
|
36
|
+
client_id: this.config.clientId,
|
|
37
|
+
redirect_uri: this.config.redirectUri,
|
|
38
|
+
response_type: 'code',
|
|
39
|
+
scope: scopes.join(' '),
|
|
40
|
+
state,
|
|
41
|
+
code_challenge: pkce.challenge,
|
|
42
|
+
code_challenge_method: pkce.method,
|
|
43
|
+
});
|
|
44
|
+
return `${this.baseUrl}/oauth/authorize?${query.toString()}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Canje del `code` del callback. El código dura 10 minutos y un solo uso.
|
|
48
|
+
*
|
|
49
|
+
* Acepta el challenge completo o solo `{ verifier }`: en un flujo real el
|
|
50
|
+
* verifier se recupera de la sesión del usuario y el challenge ya no hace
|
|
51
|
+
* falta (lo tiene el servidor, que lo comparará con el hash del verifier).
|
|
52
|
+
*/
|
|
53
|
+
async exchangeCode(code, pkce) {
|
|
54
|
+
return this.tokenRequest({
|
|
55
|
+
grant_type: 'authorization_code',
|
|
56
|
+
code,
|
|
57
|
+
redirect_uri: this.config.redirectUri,
|
|
58
|
+
code_verifier: pkce.verifier,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Refresco CON ROTACIÓN: el refresh que pasas queda invalidado y el TokenSet
|
|
63
|
+
* devuelto trae uno nuevo. Persístelo antes de volver a llamar a la API (ver
|
|
64
|
+
* tokens.ts) — reusar el viejo revoca el grant entero.
|
|
65
|
+
*/
|
|
66
|
+
async refresh(refreshToken) {
|
|
67
|
+
return this.tokenRequest({ grant_type: 'refresh_token', refresh_token: refreshToken });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Revocación RFC 7009. Con un refresh token cae el grant ENTERO (todos los
|
|
71
|
+
* access tokens de tu app para ese usuario); con un access token, solo ese.
|
|
72
|
+
* Llámalo cuando el usuario desconecte tu app: es la cortesía mínima.
|
|
73
|
+
*/
|
|
74
|
+
async revoke(token) {
|
|
75
|
+
const response = await this.doFetch(`${this.baseUrl}/oauth/revoke`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: this.formHeaders(),
|
|
78
|
+
body: this.formBody({ token }),
|
|
79
|
+
});
|
|
80
|
+
// 200 aunque el token no exista (el AS no filtra si existía).
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const body = await safeJson(response);
|
|
83
|
+
throw new OAuthError(body?.error ?? 'revocation_failed', body?.error_description ?? `HTTP ${response.status}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async tokenRequest(params) {
|
|
87
|
+
const response = await this.doFetch(`${this.baseUrl}/oauth/token`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: this.formHeaders(),
|
|
90
|
+
body: this.formBody(params),
|
|
91
|
+
});
|
|
92
|
+
const body = await safeJson(response);
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const error = body;
|
|
95
|
+
throw new OAuthError(error?.error ?? 'token_request_failed', error?.error_description);
|
|
96
|
+
}
|
|
97
|
+
return tokenSetFromResponse(body);
|
|
98
|
+
}
|
|
99
|
+
formHeaders() {
|
|
100
|
+
return {
|
|
101
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
102
|
+
accept: 'application/json',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
formBody(params) {
|
|
106
|
+
const body = new URLSearchParams({ ...params, client_id: this.config.clientId });
|
|
107
|
+
if (this.config.clientSecret) {
|
|
108
|
+
body.set('client_secret', this.config.clientSecret);
|
|
109
|
+
}
|
|
110
|
+
return body.toString();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* PKCE S256. Obligatorio para clients públicos y recomendable siempre: liga el
|
|
115
|
+
* código de autorización a quien lo pidió. Guarda el `verifier` en la sesión
|
|
116
|
+
* del usuario hasta que vuelva del callback.
|
|
117
|
+
*/
|
|
118
|
+
export async function createPkceChallenge() {
|
|
119
|
+
const bytes = new Uint8Array(32);
|
|
120
|
+
crypto.getRandomValues(bytes);
|
|
121
|
+
const verifier = base64Url(bytes);
|
|
122
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
123
|
+
return { verifier, challenge: base64Url(new Uint8Array(digest)), method: 'S256' };
|
|
124
|
+
}
|
|
125
|
+
/** `state` anti-CSRF. Compáralo al volver del callback. */
|
|
126
|
+
export function createState() {
|
|
127
|
+
const bytes = new Uint8Array(16);
|
|
128
|
+
crypto.getRandomValues(bytes);
|
|
129
|
+
return base64Url(bytes);
|
|
130
|
+
}
|
|
131
|
+
function base64Url(bytes) {
|
|
132
|
+
let binary = '';
|
|
133
|
+
for (const byte of bytes)
|
|
134
|
+
binary += String.fromCharCode(byte);
|
|
135
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
136
|
+
}
|
|
137
|
+
async function safeJson(response) {
|
|
138
|
+
const text = await response.text();
|
|
139
|
+
if (text === '')
|
|
140
|
+
return null;
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(text);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return text;
|
|
146
|
+
}
|
|
147
|
+
}
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokens y su persistencia.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ LO MÁS IMPORTANTE DE ESTE SDK. El refresh token de Pimia **rota**: cada
|
|
5
|
+
* canje devuelve uno nuevo y mata el anterior, y **reusar uno ya rotado se
|
|
6
|
+
* trata como robo: revoca el grant entero en cascada** (todos los tokens de tu
|
|
7
|
+
* app para ese usuario mueren y el usuario tiene que volver a autorizarte).
|
|
8
|
+
*
|
|
9
|
+
* Por eso el SDK nunca guarda tokens «en una variable y ya»: exige un
|
|
10
|
+
* TokenStore y persiste el conjunto ENTERO tras cada refresh. Si tu app corre
|
|
11
|
+
* en varios procesos, el store debe ser compartido (Redis, BD…) y, si dos
|
|
12
|
+
* procesos pueden refrescar a la vez, serializa el refresh — dos refrescos
|
|
13
|
+
* concurrentes con el mismo token son, para el servidor, un reuse.
|
|
14
|
+
*/
|
|
15
|
+
export interface TokenSet {
|
|
16
|
+
accessToken: string;
|
|
17
|
+
/** Ausente si el operador desactivó los refresh (OAUTH_ACCESS_TOKEN_TTL=0). */
|
|
18
|
+
refreshToken?: string;
|
|
19
|
+
/** Epoch en ms. Ausente = el servidor no dio expiración. */
|
|
20
|
+
expiresAt?: number;
|
|
21
|
+
scope?: string;
|
|
22
|
+
tokenType?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface TokenStore {
|
|
25
|
+
load(): Promise<TokenSet | null> | TokenSet | null;
|
|
26
|
+
save(tokens: TokenSet): Promise<void> | void;
|
|
27
|
+
clear(): Promise<void> | void;
|
|
28
|
+
}
|
|
29
|
+
/** Store de memoria: vale para scripts y tests, NO para producción con varios procesos. */
|
|
30
|
+
export declare class MemoryTokenStore implements TokenStore {
|
|
31
|
+
private tokens;
|
|
32
|
+
constructor(tokens?: TokenSet | null);
|
|
33
|
+
load(): TokenSet | null;
|
|
34
|
+
save(tokens: TokenSet): void;
|
|
35
|
+
clear(): void;
|
|
36
|
+
}
|
|
37
|
+
/** ¿Caduca dentro de `skewSeconds`? Sin expiresAt se asume que sigue vivo. */
|
|
38
|
+
export declare function isExpired(tokens: TokenSet, skewSeconds?: number, now?: number): boolean;
|
|
39
|
+
/** Respuesta cruda del token endpoint → TokenSet. */
|
|
40
|
+
export declare function tokenSetFromResponse(payload: {
|
|
41
|
+
access_token: string;
|
|
42
|
+
refresh_token?: string;
|
|
43
|
+
expires_in?: number;
|
|
44
|
+
scope?: string;
|
|
45
|
+
token_type?: string;
|
|
46
|
+
}, now?: number): TokenSet;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokens y su persistencia.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ LO MÁS IMPORTANTE DE ESTE SDK. El refresh token de Pimia **rota**: cada
|
|
5
|
+
* canje devuelve uno nuevo y mata el anterior, y **reusar uno ya rotado se
|
|
6
|
+
* trata como robo: revoca el grant entero en cascada** (todos los tokens de tu
|
|
7
|
+
* app para ese usuario mueren y el usuario tiene que volver a autorizarte).
|
|
8
|
+
*
|
|
9
|
+
* Por eso el SDK nunca guarda tokens «en una variable y ya»: exige un
|
|
10
|
+
* TokenStore y persiste el conjunto ENTERO tras cada refresh. Si tu app corre
|
|
11
|
+
* en varios procesos, el store debe ser compartido (Redis, BD…) y, si dos
|
|
12
|
+
* procesos pueden refrescar a la vez, serializa el refresh — dos refrescos
|
|
13
|
+
* concurrentes con el mismo token son, para el servidor, un reuse.
|
|
14
|
+
*/
|
|
15
|
+
/** Store de memoria: vale para scripts y tests, NO para producción con varios procesos. */
|
|
16
|
+
export class MemoryTokenStore {
|
|
17
|
+
tokens;
|
|
18
|
+
constructor(tokens = null) {
|
|
19
|
+
this.tokens = tokens;
|
|
20
|
+
}
|
|
21
|
+
load() {
|
|
22
|
+
return this.tokens;
|
|
23
|
+
}
|
|
24
|
+
save(tokens) {
|
|
25
|
+
this.tokens = tokens;
|
|
26
|
+
}
|
|
27
|
+
clear() {
|
|
28
|
+
this.tokens = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** ¿Caduca dentro de `skewSeconds`? Sin expiresAt se asume que sigue vivo. */
|
|
32
|
+
export function isExpired(tokens, skewSeconds = 60, now = Date.now()) {
|
|
33
|
+
if (tokens.expiresAt === undefined)
|
|
34
|
+
return false;
|
|
35
|
+
return tokens.expiresAt - skewSeconds * 1000 <= now;
|
|
36
|
+
}
|
|
37
|
+
/** Respuesta cruda del token endpoint → TokenSet. */
|
|
38
|
+
export function tokenSetFromResponse(payload, now = Date.now()) {
|
|
39
|
+
return {
|
|
40
|
+
accessToken: payload.access_token,
|
|
41
|
+
refreshToken: payload.refresh_token,
|
|
42
|
+
expiresAt: payload.expires_in ? now + payload.expires_in * 1000 : undefined,
|
|
43
|
+
scope: payload.scope,
|
|
44
|
+
tokenType: payload.token_type ?? 'bearer',
|
|
45
|
+
};
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pimia/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cliente TypeScript de la API de Pimia para apps de partner: OAuth con PKCE, rotación de refresh persistida, reintentos de rate limit y tipos generados del OpenAPI.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Pimia (https://pimia.es)",
|
|
7
|
+
"homepage": "https://github.com/Pimia-AI/pimia-sdks/tree/main/typescript#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/Pimia-AI/pimia-sdks/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Pimia-AI/pimia-sdks.git",
|
|
14
|
+
"directory": "typescript"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./api": {
|
|
29
|
+
"types": "./dist/api.d.ts",
|
|
30
|
+
"import": "./dist/api.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.json",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
+
"generate:types": "openapi-typescript ../spec/pimia-api-v1.json -o src/api.ts",
|
|
44
|
+
"test": "node --test test/*.test.js",
|
|
45
|
+
"prepublishOnly": "npm run build"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"openapi-typescript": "^7.4.4",
|
|
49
|
+
"typescript": "^5.7.2"
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"pimia",
|
|
53
|
+
"facturacion",
|
|
54
|
+
"verifactu",
|
|
55
|
+
"oauth",
|
|
56
|
+
"sdk"
|
|
57
|
+
]
|
|
58
|
+
}
|