@sanghosdk/oauth 0.2.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 +130 -0
- package/dist/index.cjs +235 -0
- package/dist/index.d.cts +105 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.js +202 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sangho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# @sanghosdk/oauth
|
|
2
|
+
|
|
3
|
+
« Se connecter avec Sangho » — client OAuth2 Authorization Code + PKCE (RFC 7636).
|
|
4
|
+
|
|
5
|
+
**Librairie distincte du SDK de paiement (`@sanghosdk/js`)** : l'authentification d'une personne
|
|
6
|
+
et l'encaissement d'un paiement sont deux usages différents, avec des cycles de vie et des
|
|
7
|
+
audiences différents (c'est aussi le choix de la plupart des plateformes — l'OAuth Stripe Connect
|
|
8
|
+
ne vit pas dans le SDK `stripe`). N'importez cette librairie que si vous ajoutez un bouton
|
|
9
|
+
« Se connecter avec Sangho », pas pour accepter des paiements.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @sanghosdk/oauth
|
|
15
|
+
# ou : npm install @sanghosdk/oauth / yarn add @sanghosdk/oauth
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Prérequis côté dashboard Sangho
|
|
19
|
+
|
|
20
|
+
Créez un client OAuth (`dash.sangho.ga` → Clés API → Clients OAuth) et enregistrez l'URI de redirection EXACTE que
|
|
21
|
+
vous utiliserez. Vous obtenez un `client_id` et un `client_secret` (affiché une seule fois). Ces clients sont
|
|
22
|
+
*confidentiels* : le secret est **obligatoire** pour échanger le code, rafraîchir et révoquer un jeton, en plus de PKCE.
|
|
23
|
+
|
|
24
|
+
> ⚠️ `clientSecret` ne doit être utilisé que **côté serveur** (Node, SSR, BFF). Jamais dans un navigateur, une
|
|
25
|
+
> application mobile ou un bundle public : pour une SPA, faites passer l'échange du code par votre serveur.
|
|
26
|
+
|
|
27
|
+
## Options
|
|
28
|
+
|
|
29
|
+
| Option | Défaut | Rôle |
|
|
30
|
+
| -------------- | ------------------------ | ---- |
|
|
31
|
+
| `clientId` | — | Identifiant du client (obligatoire). |
|
|
32
|
+
| `clientSecret` | — | Secret du client, côté serveur uniquement. |
|
|
33
|
+
| `redirectUri` | — | URI de retour, identique à celle enregistrée (obligatoire). |
|
|
34
|
+
| `scopes` | `["profile", "email"]` | Portées demandées. |
|
|
35
|
+
| `baseUrl` | `https://accounts.sangho.ga` | Hôte OAuth de Sangho (une base terminée par `/o` est acceptée). |
|
|
36
|
+
| `timeoutMs` | `10000` | Délai maximal d'un appel réseau. |
|
|
37
|
+
| `fetch` | `globalThis.fetch` | `fetch` à utiliser (proxy, tests, runtime sans `fetch` global). |
|
|
38
|
+
|
|
39
|
+
## Flow complet (SSR — Node/Express)
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
import { SanghoOAuthClient } from "@sanghosdk/oauth"
|
|
43
|
+
|
|
44
|
+
const sangho = new SanghoOAuthClient({
|
|
45
|
+
clientId: process.env.SANGHO_OAUTH_CLIENT_ID!,
|
|
46
|
+
clientSecret: process.env.SANGHO_OAUTH_CLIENT_SECRET!,
|
|
47
|
+
redirectUri: "https://monapp.com/auth/sangho/callback",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// 1. Redirection — GET /auth/sangho/login
|
|
51
|
+
app.get("/auth/sangho/login", async (req, res) => {
|
|
52
|
+
const { url, codeVerifier, state } = await sangho.createAuthorizationRequest()
|
|
53
|
+
// codeVerifier et state doivent survivre à l'aller-retour : session serveur ou cookie httpOnly signé.
|
|
54
|
+
req.session.oauthCodeVerifier = codeVerifier
|
|
55
|
+
req.session.oauthState = state
|
|
56
|
+
res.redirect(url)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// 2. Callback — GET /auth/sangho/callback?code=...&state=...
|
|
60
|
+
app.get("/auth/sangho/callback", async (req, res) => {
|
|
61
|
+
try {
|
|
62
|
+
const tokens = await sangho.exchangeCode({
|
|
63
|
+
code: req.query.code as string,
|
|
64
|
+
codeVerifier: req.session.oauthCodeVerifier,
|
|
65
|
+
receivedState: req.query.state as string,
|
|
66
|
+
expectedState: req.session.oauthState,
|
|
67
|
+
})
|
|
68
|
+
const profile = await sangho.getUserInfo(tokens.access_token)
|
|
69
|
+
// profile.sub est l'identifiant STABLE à stocker (jamais profile.email, modifiable).
|
|
70
|
+
req.session.user = { sub: profile.sub, email: profile.email }
|
|
71
|
+
res.redirect("/dashboard")
|
|
72
|
+
} catch (err) {
|
|
73
|
+
res.redirect("/auth/sangho/login?error=1")
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Rafraîchir un jeton expiré
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
const tokens = await sangho.refreshToken(storedRefreshToken)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Scopes
|
|
85
|
+
|
|
86
|
+
| Scope | Revendications ajoutées à `getUserInfo()` |
|
|
87
|
+
| --------- | ------------------------------------------------- |
|
|
88
|
+
| `profile` | `name`, `given_name`, `family_name`, `picture` |
|
|
89
|
+
| `email` | `email`, `email_verified` |
|
|
90
|
+
|
|
91
|
+
`sub` (identifiant stable, UUID opaque) est toujours renvoyé, quel que soit le scope.
|
|
92
|
+
|
|
93
|
+
## Gestion des erreurs
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { SanghoOAuthError, SanghoOAuthStateMismatchError } from "@sanghosdk/oauth"
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
await sangho.exchangeCode({ ... })
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err instanceof SanghoOAuthStateMismatchError) {
|
|
102
|
+
// `state` invalide — tentative de CSRF ou session expirée, ne PAS poursuivre le flow.
|
|
103
|
+
} else if (err instanceof SanghoOAuthError) {
|
|
104
|
+
// code : "invalid_client" (secret refusé), "invalid_grant" (code expiré ou déjà utilisé),
|
|
105
|
+
// "network_error", "timeout", "invalid_response"…
|
|
106
|
+
console.error(err.code, err.description)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Développement et publication
|
|
112
|
+
|
|
113
|
+
Le projet utilise **pnpm** (version épinglée dans `packageManager` ; `corepack enable` active la bonne). Un
|
|
114
|
+
`Makefile` regroupe les commandes (`make help`) ; la logique qui dépasse un simple `pnpm …` est dans `scripts/dev.mjs` (Node
|
|
115
|
+
pur, sans shell POSIX : ça fonctionne sous Windows/PowerShell comme sous Linux et macOS) ; la version de Node recommandée est dans `.nvmrc` (Node ≥ 20 requis :
|
|
116
|
+
`globalThis.crypto` n'existe pas par défaut sous Node 18).
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
corepack enable # une fois, pour obtenir pnpm
|
|
120
|
+
make install # pnpm install --frozen-lockfile
|
|
121
|
+
make test # vitest
|
|
122
|
+
make typecheck # tsc
|
|
123
|
+
make build # dist/ (ESM + CJS + types)
|
|
124
|
+
make smoke # installe l'archive dans un dossier vide et l'importe en ESM et en CommonJS
|
|
125
|
+
make matrix # tests sur Node 20, 22 et 24 (via pnpm dlx node@X, sans nvm)
|
|
126
|
+
make check # tout ce que la CI vérifie
|
|
127
|
+
make release # tag vX.Y.Z → .github/workflows/release.yml publie sur npm (secret NPM_TOKEN)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Sans `make` : `pnpm install`, `pnpm test`, `pnpm run build`… (voir `scripts` dans `package.json`).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
SanghoOAuthClient: () => SanghoOAuthClient,
|
|
24
|
+
SanghoOAuthError: () => SanghoOAuthError,
|
|
25
|
+
SanghoOAuthStateMismatchError: () => SanghoOAuthStateMismatchError,
|
|
26
|
+
generateCodeChallenge: () => generateCodeChallenge,
|
|
27
|
+
generateCodeVerifier: () => generateCodeVerifier,
|
|
28
|
+
generateState: () => generateState,
|
|
29
|
+
normalizeBaseUrl: () => normalizeBaseUrl
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(index_exports);
|
|
32
|
+
|
|
33
|
+
// src/pkce.ts
|
|
34
|
+
function getCrypto() {
|
|
35
|
+
const c = globalThis.crypto;
|
|
36
|
+
if (!c || !c.subtle) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Web Crypto API indisponible (globalThis.crypto.subtle). Node >= 20 ou un navigateur moderne sont requis."
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
function base64UrlEncode(bytes) {
|
|
44
|
+
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
45
|
+
let binary = "";
|
|
46
|
+
for (const b of arr) binary += String.fromCharCode(b);
|
|
47
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
48
|
+
}
|
|
49
|
+
function generateCodeVerifier() {
|
|
50
|
+
const bytes = new Uint8Array(32);
|
|
51
|
+
getCrypto().getRandomValues(bytes);
|
|
52
|
+
return base64UrlEncode(bytes);
|
|
53
|
+
}
|
|
54
|
+
async function generateCodeChallenge(verifier) {
|
|
55
|
+
const data = new TextEncoder().encode(verifier);
|
|
56
|
+
const digest = await getCrypto().subtle.digest("SHA-256", data);
|
|
57
|
+
return base64UrlEncode(digest);
|
|
58
|
+
}
|
|
59
|
+
function generateState() {
|
|
60
|
+
return generateCodeVerifier();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/errors.ts
|
|
64
|
+
var SanghoOAuthError = class extends Error {
|
|
65
|
+
constructor(message, options) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "SanghoOAuthError";
|
|
68
|
+
this.code = options?.code;
|
|
69
|
+
this.description = options?.description;
|
|
70
|
+
this.statusCode = options?.statusCode;
|
|
71
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var SanghoOAuthStateMismatchError = class extends SanghoOAuthError {
|
|
75
|
+
constructor() {
|
|
76
|
+
super("The `state` parameter does not match the one issued for this authorization request.", {
|
|
77
|
+
code: "state_mismatch"
|
|
78
|
+
});
|
|
79
|
+
this.name = "SanghoOAuthStateMismatchError";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// src/client.ts
|
|
84
|
+
var DEFAULT_BASE_URL = "https://accounts.sangho.ga";
|
|
85
|
+
var DEFAULT_SCOPES = ["profile", "email"];
|
|
86
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
87
|
+
function normalizeBaseUrl(url) {
|
|
88
|
+
return url.trim().replace(/\/+$/, "").replace(/\/o$/, "").replace(/\/+$/, "");
|
|
89
|
+
}
|
|
90
|
+
function sameSecret(a, b) {
|
|
91
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
92
|
+
let diff = 0;
|
|
93
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
94
|
+
return diff === 0;
|
|
95
|
+
}
|
|
96
|
+
var SanghoOAuthClient = class {
|
|
97
|
+
constructor(config) {
|
|
98
|
+
if (!config.clientId) throw new SanghoOAuthError("`clientId` is required.");
|
|
99
|
+
if (!config.redirectUri) throw new SanghoOAuthError("`redirectUri` is required.");
|
|
100
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
101
|
+
if (!fetchImpl) throw new SanghoOAuthError("No `fetch` implementation available (Node >= 20 or pass `fetch`).");
|
|
102
|
+
this.config = {
|
|
103
|
+
clientId: config.clientId,
|
|
104
|
+
clientSecret: config.clientSecret || void 0,
|
|
105
|
+
redirectUri: config.redirectUri,
|
|
106
|
+
baseUrl: normalizeBaseUrl(config.baseUrl ?? DEFAULT_BASE_URL),
|
|
107
|
+
scopes: config.scopes ? [...config.scopes] : [...DEFAULT_SCOPES],
|
|
108
|
+
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
109
|
+
fetch: fetchImpl.bind(globalThis)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Construit l'URL d'autorisation (`/o/authorize/`) et génère le couple PKCE + `state`.
|
|
114
|
+
* L'appelant DOIT persister `codeVerifier` et `state` (session serveur, ou cookie httpOnly signé) le temps de
|
|
115
|
+
* l'aller-retour, puis les repasser à `exchangeCode`.
|
|
116
|
+
*/
|
|
117
|
+
async createAuthorizationRequest() {
|
|
118
|
+
const codeVerifier = generateCodeVerifier();
|
|
119
|
+
const state = generateState();
|
|
120
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
121
|
+
const params = new URLSearchParams({
|
|
122
|
+
response_type: "code",
|
|
123
|
+
client_id: this.config.clientId,
|
|
124
|
+
redirect_uri: this.config.redirectUri,
|
|
125
|
+
scope: this.config.scopes.join(" "),
|
|
126
|
+
state,
|
|
127
|
+
code_challenge: codeChallenge,
|
|
128
|
+
code_challenge_method: "S256"
|
|
129
|
+
});
|
|
130
|
+
return { url: `${this.config.baseUrl}/o/authorize/?${params.toString()}`, codeVerifier, state };
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Vérifie le `state` renvoyé par le callback puis échange le `code` contre des jetons (`/o/token/`).
|
|
134
|
+
* Lève `SanghoOAuthStateMismatchError` avant tout appel réseau si les `state` ne correspondent pas (protection CSRF).
|
|
135
|
+
*/
|
|
136
|
+
async exchangeCode(params) {
|
|
137
|
+
if (!sameSecret(params.receivedState, params.expectedState)) {
|
|
138
|
+
throw new SanghoOAuthStateMismatchError();
|
|
139
|
+
}
|
|
140
|
+
return this.postToken({
|
|
141
|
+
grant_type: "authorization_code",
|
|
142
|
+
code: params.code,
|
|
143
|
+
redirect_uri: this.config.redirectUri,
|
|
144
|
+
code_verifier: params.codeVerifier
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Rafraîchit un jeton expiré (un `refresh_token` n'est renvoyé que si le serveur en émet pour ce client). */
|
|
148
|
+
async refreshToken(refreshToken) {
|
|
149
|
+
return this.postToken({ grant_type: "refresh_token", refresh_token: refreshToken });
|
|
150
|
+
}
|
|
151
|
+
/** Révoque un jeton (déconnexion explicite). */
|
|
152
|
+
async revokeToken(token) {
|
|
153
|
+
const { response, data } = await this.send("/o/revoke_token/", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
156
|
+
body: this.clientAuth({ token })
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) throw this.toError(response, data, "Failed to revoke token.");
|
|
159
|
+
}
|
|
160
|
+
/** Profil du porteur du jeton (`/o/userinfo/`) — les champs renvoyés dépendent des scopes accordés. */
|
|
161
|
+
async getUserInfo(accessToken) {
|
|
162
|
+
const { response, data } = await this.send("/o/userinfo/", {
|
|
163
|
+
method: "GET",
|
|
164
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
165
|
+
});
|
|
166
|
+
if (!response.ok) throw this.toError(response, data, "Failed to fetch user info.");
|
|
167
|
+
if (typeof data.sub !== "string" || !data.sub) {
|
|
168
|
+
throw new SanghoOAuthError("User info response has no `sub` claim.", { code: "invalid_response" });
|
|
169
|
+
}
|
|
170
|
+
return data;
|
|
171
|
+
}
|
|
172
|
+
// ── interne ────────────────────────────────────────────────────────────────
|
|
173
|
+
clientAuth(fields) {
|
|
174
|
+
const body = new URLSearchParams({ ...fields, client_id: this.config.clientId });
|
|
175
|
+
if (this.config.clientSecret) body.set("client_secret", this.config.clientSecret);
|
|
176
|
+
return body.toString();
|
|
177
|
+
}
|
|
178
|
+
async postToken(fields) {
|
|
179
|
+
const { response, data } = await this.send("/o/token/", {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
182
|
+
body: this.clientAuth(fields)
|
|
183
|
+
});
|
|
184
|
+
if (!response.ok) throw this.toError(response, data, "Token request failed.");
|
|
185
|
+
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
186
|
+
throw new SanghoOAuthError("Token response has no `access_token`.", {
|
|
187
|
+
code: "invalid_response",
|
|
188
|
+
statusCode: response.status
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return data;
|
|
192
|
+
}
|
|
193
|
+
toError(response, data, fallback) {
|
|
194
|
+
const description = typeof data.error_description === "string" ? data.error_description : void 0;
|
|
195
|
+
return new SanghoOAuthError(description ?? fallback, {
|
|
196
|
+
code: typeof data.error === "string" ? data.error : void 0,
|
|
197
|
+
description,
|
|
198
|
+
statusCode: response.status
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
async send(path, init) {
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
204
|
+
try {
|
|
205
|
+
const response = await this.config.fetch(`${this.config.baseUrl}${path}`, {
|
|
206
|
+
...init,
|
|
207
|
+
headers: { Accept: "application/json", ...init.headers },
|
|
208
|
+
redirect: "error",
|
|
209
|
+
// un endpoint OAuth ne redirige jamais : ne pas suivre (et ne pas renvoyer un secret ailleurs)
|
|
210
|
+
signal: controller.signal
|
|
211
|
+
});
|
|
212
|
+
const parsed = await response.json().catch(() => ({}));
|
|
213
|
+
const data = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
214
|
+
return { response, data };
|
|
215
|
+
} catch (err) {
|
|
216
|
+
if (err instanceof SanghoOAuthError) throw err;
|
|
217
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
218
|
+
throw new SanghoOAuthError(aborted ? "Sangho request timed out." : `Could not reach Sangho: ${err.message}`, {
|
|
219
|
+
code: aborted ? "timeout" : "network_error"
|
|
220
|
+
});
|
|
221
|
+
} finally {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
227
|
+
0 && (module.exports = {
|
|
228
|
+
SanghoOAuthClient,
|
|
229
|
+
SanghoOAuthError,
|
|
230
|
+
SanghoOAuthStateMismatchError,
|
|
231
|
+
generateCodeChallenge,
|
|
232
|
+
generateCodeVerifier,
|
|
233
|
+
generateState,
|
|
234
|
+
normalizeBaseUrl
|
|
235
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Portées reconnues par /o/userinfo/ (cf. backend/dash/oauth/claims.py côté Sangho). */
|
|
2
|
+
type SanghoOAuthScope = "profile" | "email";
|
|
3
|
+
interface SanghoOAuthConfig {
|
|
4
|
+
/** Identifiant du client OAuth (créé dans le tableau de bord Sangho). */
|
|
5
|
+
clientId: string;
|
|
6
|
+
/**
|
|
7
|
+
* Secret du client. Les clients créés dans le tableau de bord Sangho sont CONFIDENTIELS : le secret est obligatoire
|
|
8
|
+
* pour échanger le code, rafraîchir et révoquer un jeton. À utiliser UNIQUEMENT côté serveur — jamais dans un
|
|
9
|
+
* navigateur, une application mobile ou un bundle public.
|
|
10
|
+
*/
|
|
11
|
+
clientSecret?: string;
|
|
12
|
+
/** URI de redirection, doit correspondre EXACTEMENT à celle enregistrée pour ce client. */
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
/** Portées demandées. Par défaut `["profile", "email"]`. */
|
|
15
|
+
scopes?: SanghoOAuthScope[];
|
|
16
|
+
/** Hôte du fournisseur OAuth2 Sangho (une base terminée par `/o` est acceptée). Par défaut `https://accounts.sangho.ga`. */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** Délai maximal d'un appel réseau, en millisecondes. Par défaut 10 000. */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Implémentation de `fetch` à utiliser (proxy, tests, runtime sans `fetch` global). Par défaut `globalThis.fetch`. */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
interface AuthorizationRequest {
|
|
24
|
+
/** URL complète à laquelle rediriger l'utilisateur (`/o/authorize/?...`). */
|
|
25
|
+
url: string;
|
|
26
|
+
/** À conserver côté serveur (session, cookie httpOnly signé) le temps de l'aller-retour — jamais dans l'URL de retour. */
|
|
27
|
+
codeVerifier: string;
|
|
28
|
+
/** À conserver de la même façon et à comparer avec le `state` du callback (protection CSRF). */
|
|
29
|
+
state: string;
|
|
30
|
+
}
|
|
31
|
+
interface TokenResponse {
|
|
32
|
+
access_token: string;
|
|
33
|
+
refresh_token?: string;
|
|
34
|
+
token_type: string;
|
|
35
|
+
expires_in: number;
|
|
36
|
+
scope: string;
|
|
37
|
+
}
|
|
38
|
+
/** Revendications OIDC-lite de /o/userinfo/ — un champ est absent si le jeton n'a pas la portée correspondante. */
|
|
39
|
+
interface SanghoUserInfo {
|
|
40
|
+
sub: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
given_name?: string;
|
|
43
|
+
family_name?: string;
|
|
44
|
+
picture?: string | null;
|
|
45
|
+
email?: string;
|
|
46
|
+
email_verified?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `https://h/`, `https://h/o` et `https://h/o/` désignent le même hôte : évite les `/o/o/token/`. */
|
|
50
|
+
declare function normalizeBaseUrl(url: string): string;
|
|
51
|
+
declare class SanghoOAuthClient {
|
|
52
|
+
private readonly config;
|
|
53
|
+
constructor(config: SanghoOAuthConfig);
|
|
54
|
+
/**
|
|
55
|
+
* Construit l'URL d'autorisation (`/o/authorize/`) et génère le couple PKCE + `state`.
|
|
56
|
+
* L'appelant DOIT persister `codeVerifier` et `state` (session serveur, ou cookie httpOnly signé) le temps de
|
|
57
|
+
* l'aller-retour, puis les repasser à `exchangeCode`.
|
|
58
|
+
*/
|
|
59
|
+
createAuthorizationRequest(): Promise<AuthorizationRequest>;
|
|
60
|
+
/**
|
|
61
|
+
* Vérifie le `state` renvoyé par le callback puis échange le `code` contre des jetons (`/o/token/`).
|
|
62
|
+
* Lève `SanghoOAuthStateMismatchError` avant tout appel réseau si les `state` ne correspondent pas (protection CSRF).
|
|
63
|
+
*/
|
|
64
|
+
exchangeCode(params: {
|
|
65
|
+
code: string;
|
|
66
|
+
codeVerifier: string;
|
|
67
|
+
receivedState: string;
|
|
68
|
+
expectedState: string;
|
|
69
|
+
}): Promise<TokenResponse>;
|
|
70
|
+
/** Rafraîchit un jeton expiré (un `refresh_token` n'est renvoyé que si le serveur en émet pour ce client). */
|
|
71
|
+
refreshToken(refreshToken: string): Promise<TokenResponse>;
|
|
72
|
+
/** Révoque un jeton (déconnexion explicite). */
|
|
73
|
+
revokeToken(token: string): Promise<void>;
|
|
74
|
+
/** Profil du porteur du jeton (`/o/userinfo/`) — les champs renvoyés dépendent des scopes accordés. */
|
|
75
|
+
getUserInfo(accessToken: string): Promise<SanghoUserInfo>;
|
|
76
|
+
private clientAuth;
|
|
77
|
+
private postToken;
|
|
78
|
+
private toError;
|
|
79
|
+
private send;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Génère un `code_verifier` aléatoire (43-128 caractères, alphabet non-réservé RFC 7636 §4.1). */
|
|
83
|
+
declare function generateCodeVerifier(): string;
|
|
84
|
+
/** Dérive le `code_challenge` (méthode S256, seule acceptée par le serveur OAuth2 de Sangho). */
|
|
85
|
+
declare function generateCodeChallenge(verifier: string): Promise<string>;
|
|
86
|
+
/** État anti-CSRF (OAuth2 `state`) — même génération que le code_verifier, usage différent. */
|
|
87
|
+
declare function generateState(): string;
|
|
88
|
+
|
|
89
|
+
declare class SanghoOAuthError extends Error {
|
|
90
|
+
/** Code renvoyé par le endpoint token/userinfo (`error`), ex: "invalid_grant", "invalid_client". */
|
|
91
|
+
readonly code?: string;
|
|
92
|
+
readonly description?: string;
|
|
93
|
+
readonly statusCode?: number;
|
|
94
|
+
constructor(message: string, options?: {
|
|
95
|
+
code?: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
statusCode?: number;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** `state` renvoyé par le callback ne correspond pas à celui émis à l'aller — signe possible de CSRF. */
|
|
101
|
+
declare class SanghoOAuthStateMismatchError extends SanghoOAuthError {
|
|
102
|
+
constructor();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { type AuthorizationRequest, SanghoOAuthClient, type SanghoOAuthConfig, SanghoOAuthError, type SanghoOAuthScope, SanghoOAuthStateMismatchError, type SanghoUserInfo, type TokenResponse, generateCodeChallenge, generateCodeVerifier, generateState, normalizeBaseUrl };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** Portées reconnues par /o/userinfo/ (cf. backend/dash/oauth/claims.py côté Sangho). */
|
|
2
|
+
type SanghoOAuthScope = "profile" | "email";
|
|
3
|
+
interface SanghoOAuthConfig {
|
|
4
|
+
/** Identifiant du client OAuth (créé dans le tableau de bord Sangho). */
|
|
5
|
+
clientId: string;
|
|
6
|
+
/**
|
|
7
|
+
* Secret du client. Les clients créés dans le tableau de bord Sangho sont CONFIDENTIELS : le secret est obligatoire
|
|
8
|
+
* pour échanger le code, rafraîchir et révoquer un jeton. À utiliser UNIQUEMENT côté serveur — jamais dans un
|
|
9
|
+
* navigateur, une application mobile ou un bundle public.
|
|
10
|
+
*/
|
|
11
|
+
clientSecret?: string;
|
|
12
|
+
/** URI de redirection, doit correspondre EXACTEMENT à celle enregistrée pour ce client. */
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
/** Portées demandées. Par défaut `["profile", "email"]`. */
|
|
15
|
+
scopes?: SanghoOAuthScope[];
|
|
16
|
+
/** Hôte du fournisseur OAuth2 Sangho (une base terminée par `/o` est acceptée). Par défaut `https://accounts.sangho.ga`. */
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
/** Délai maximal d'un appel réseau, en millisecondes. Par défaut 10 000. */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Implémentation de `fetch` à utiliser (proxy, tests, runtime sans `fetch` global). Par défaut `globalThis.fetch`. */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
interface AuthorizationRequest {
|
|
24
|
+
/** URL complète à laquelle rediriger l'utilisateur (`/o/authorize/?...`). */
|
|
25
|
+
url: string;
|
|
26
|
+
/** À conserver côté serveur (session, cookie httpOnly signé) le temps de l'aller-retour — jamais dans l'URL de retour. */
|
|
27
|
+
codeVerifier: string;
|
|
28
|
+
/** À conserver de la même façon et à comparer avec le `state` du callback (protection CSRF). */
|
|
29
|
+
state: string;
|
|
30
|
+
}
|
|
31
|
+
interface TokenResponse {
|
|
32
|
+
access_token: string;
|
|
33
|
+
refresh_token?: string;
|
|
34
|
+
token_type: string;
|
|
35
|
+
expires_in: number;
|
|
36
|
+
scope: string;
|
|
37
|
+
}
|
|
38
|
+
/** Revendications OIDC-lite de /o/userinfo/ — un champ est absent si le jeton n'a pas la portée correspondante. */
|
|
39
|
+
interface SanghoUserInfo {
|
|
40
|
+
sub: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
given_name?: string;
|
|
43
|
+
family_name?: string;
|
|
44
|
+
picture?: string | null;
|
|
45
|
+
email?: string;
|
|
46
|
+
email_verified?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `https://h/`, `https://h/o` et `https://h/o/` désignent le même hôte : évite les `/o/o/token/`. */
|
|
50
|
+
declare function normalizeBaseUrl(url: string): string;
|
|
51
|
+
declare class SanghoOAuthClient {
|
|
52
|
+
private readonly config;
|
|
53
|
+
constructor(config: SanghoOAuthConfig);
|
|
54
|
+
/**
|
|
55
|
+
* Construit l'URL d'autorisation (`/o/authorize/`) et génère le couple PKCE + `state`.
|
|
56
|
+
* L'appelant DOIT persister `codeVerifier` et `state` (session serveur, ou cookie httpOnly signé) le temps de
|
|
57
|
+
* l'aller-retour, puis les repasser à `exchangeCode`.
|
|
58
|
+
*/
|
|
59
|
+
createAuthorizationRequest(): Promise<AuthorizationRequest>;
|
|
60
|
+
/**
|
|
61
|
+
* Vérifie le `state` renvoyé par le callback puis échange le `code` contre des jetons (`/o/token/`).
|
|
62
|
+
* Lève `SanghoOAuthStateMismatchError` avant tout appel réseau si les `state` ne correspondent pas (protection CSRF).
|
|
63
|
+
*/
|
|
64
|
+
exchangeCode(params: {
|
|
65
|
+
code: string;
|
|
66
|
+
codeVerifier: string;
|
|
67
|
+
receivedState: string;
|
|
68
|
+
expectedState: string;
|
|
69
|
+
}): Promise<TokenResponse>;
|
|
70
|
+
/** Rafraîchit un jeton expiré (un `refresh_token` n'est renvoyé que si le serveur en émet pour ce client). */
|
|
71
|
+
refreshToken(refreshToken: string): Promise<TokenResponse>;
|
|
72
|
+
/** Révoque un jeton (déconnexion explicite). */
|
|
73
|
+
revokeToken(token: string): Promise<void>;
|
|
74
|
+
/** Profil du porteur du jeton (`/o/userinfo/`) — les champs renvoyés dépendent des scopes accordés. */
|
|
75
|
+
getUserInfo(accessToken: string): Promise<SanghoUserInfo>;
|
|
76
|
+
private clientAuth;
|
|
77
|
+
private postToken;
|
|
78
|
+
private toError;
|
|
79
|
+
private send;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Génère un `code_verifier` aléatoire (43-128 caractères, alphabet non-réservé RFC 7636 §4.1). */
|
|
83
|
+
declare function generateCodeVerifier(): string;
|
|
84
|
+
/** Dérive le `code_challenge` (méthode S256, seule acceptée par le serveur OAuth2 de Sangho). */
|
|
85
|
+
declare function generateCodeChallenge(verifier: string): Promise<string>;
|
|
86
|
+
/** État anti-CSRF (OAuth2 `state`) — même génération que le code_verifier, usage différent. */
|
|
87
|
+
declare function generateState(): string;
|
|
88
|
+
|
|
89
|
+
declare class SanghoOAuthError extends Error {
|
|
90
|
+
/** Code renvoyé par le endpoint token/userinfo (`error`), ex: "invalid_grant", "invalid_client". */
|
|
91
|
+
readonly code?: string;
|
|
92
|
+
readonly description?: string;
|
|
93
|
+
readonly statusCode?: number;
|
|
94
|
+
constructor(message: string, options?: {
|
|
95
|
+
code?: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
statusCode?: number;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** `state` renvoyé par le callback ne correspond pas à celui émis à l'aller — signe possible de CSRF. */
|
|
101
|
+
declare class SanghoOAuthStateMismatchError extends SanghoOAuthError {
|
|
102
|
+
constructor();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { type AuthorizationRequest, SanghoOAuthClient, type SanghoOAuthConfig, SanghoOAuthError, type SanghoOAuthScope, SanghoOAuthStateMismatchError, type SanghoUserInfo, type TokenResponse, generateCodeChallenge, generateCodeVerifier, generateState, normalizeBaseUrl };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// src/pkce.ts
|
|
2
|
+
function getCrypto() {
|
|
3
|
+
const c = globalThis.crypto;
|
|
4
|
+
if (!c || !c.subtle) {
|
|
5
|
+
throw new Error(
|
|
6
|
+
"Web Crypto API indisponible (globalThis.crypto.subtle). Node >= 20 ou un navigateur moderne sont requis."
|
|
7
|
+
);
|
|
8
|
+
}
|
|
9
|
+
return c;
|
|
10
|
+
}
|
|
11
|
+
function base64UrlEncode(bytes) {
|
|
12
|
+
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
13
|
+
let binary = "";
|
|
14
|
+
for (const b of arr) binary += String.fromCharCode(b);
|
|
15
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
16
|
+
}
|
|
17
|
+
function generateCodeVerifier() {
|
|
18
|
+
const bytes = new Uint8Array(32);
|
|
19
|
+
getCrypto().getRandomValues(bytes);
|
|
20
|
+
return base64UrlEncode(bytes);
|
|
21
|
+
}
|
|
22
|
+
async function generateCodeChallenge(verifier) {
|
|
23
|
+
const data = new TextEncoder().encode(verifier);
|
|
24
|
+
const digest = await getCrypto().subtle.digest("SHA-256", data);
|
|
25
|
+
return base64UrlEncode(digest);
|
|
26
|
+
}
|
|
27
|
+
function generateState() {
|
|
28
|
+
return generateCodeVerifier();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// src/errors.ts
|
|
32
|
+
var SanghoOAuthError = class extends Error {
|
|
33
|
+
constructor(message, options) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "SanghoOAuthError";
|
|
36
|
+
this.code = options?.code;
|
|
37
|
+
this.description = options?.description;
|
|
38
|
+
this.statusCode = options?.statusCode;
|
|
39
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
var SanghoOAuthStateMismatchError = class extends SanghoOAuthError {
|
|
43
|
+
constructor() {
|
|
44
|
+
super("The `state` parameter does not match the one issued for this authorization request.", {
|
|
45
|
+
code: "state_mismatch"
|
|
46
|
+
});
|
|
47
|
+
this.name = "SanghoOAuthStateMismatchError";
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// src/client.ts
|
|
52
|
+
var DEFAULT_BASE_URL = "https://accounts.sangho.ga";
|
|
53
|
+
var DEFAULT_SCOPES = ["profile", "email"];
|
|
54
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
55
|
+
function normalizeBaseUrl(url) {
|
|
56
|
+
return url.trim().replace(/\/+$/, "").replace(/\/o$/, "").replace(/\/+$/, "");
|
|
57
|
+
}
|
|
58
|
+
function sameSecret(a, b) {
|
|
59
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
60
|
+
let diff = 0;
|
|
61
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
62
|
+
return diff === 0;
|
|
63
|
+
}
|
|
64
|
+
var SanghoOAuthClient = class {
|
|
65
|
+
constructor(config) {
|
|
66
|
+
if (!config.clientId) throw new SanghoOAuthError("`clientId` is required.");
|
|
67
|
+
if (!config.redirectUri) throw new SanghoOAuthError("`redirectUri` is required.");
|
|
68
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
69
|
+
if (!fetchImpl) throw new SanghoOAuthError("No `fetch` implementation available (Node >= 20 or pass `fetch`).");
|
|
70
|
+
this.config = {
|
|
71
|
+
clientId: config.clientId,
|
|
72
|
+
clientSecret: config.clientSecret || void 0,
|
|
73
|
+
redirectUri: config.redirectUri,
|
|
74
|
+
baseUrl: normalizeBaseUrl(config.baseUrl ?? DEFAULT_BASE_URL),
|
|
75
|
+
scopes: config.scopes ? [...config.scopes] : [...DEFAULT_SCOPES],
|
|
76
|
+
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
77
|
+
fetch: fetchImpl.bind(globalThis)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Construit l'URL d'autorisation (`/o/authorize/`) et génère le couple PKCE + `state`.
|
|
82
|
+
* L'appelant DOIT persister `codeVerifier` et `state` (session serveur, ou cookie httpOnly signé) le temps de
|
|
83
|
+
* l'aller-retour, puis les repasser à `exchangeCode`.
|
|
84
|
+
*/
|
|
85
|
+
async createAuthorizationRequest() {
|
|
86
|
+
const codeVerifier = generateCodeVerifier();
|
|
87
|
+
const state = generateState();
|
|
88
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
89
|
+
const params = new URLSearchParams({
|
|
90
|
+
response_type: "code",
|
|
91
|
+
client_id: this.config.clientId,
|
|
92
|
+
redirect_uri: this.config.redirectUri,
|
|
93
|
+
scope: this.config.scopes.join(" "),
|
|
94
|
+
state,
|
|
95
|
+
code_challenge: codeChallenge,
|
|
96
|
+
code_challenge_method: "S256"
|
|
97
|
+
});
|
|
98
|
+
return { url: `${this.config.baseUrl}/o/authorize/?${params.toString()}`, codeVerifier, state };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Vérifie le `state` renvoyé par le callback puis échange le `code` contre des jetons (`/o/token/`).
|
|
102
|
+
* Lève `SanghoOAuthStateMismatchError` avant tout appel réseau si les `state` ne correspondent pas (protection CSRF).
|
|
103
|
+
*/
|
|
104
|
+
async exchangeCode(params) {
|
|
105
|
+
if (!sameSecret(params.receivedState, params.expectedState)) {
|
|
106
|
+
throw new SanghoOAuthStateMismatchError();
|
|
107
|
+
}
|
|
108
|
+
return this.postToken({
|
|
109
|
+
grant_type: "authorization_code",
|
|
110
|
+
code: params.code,
|
|
111
|
+
redirect_uri: this.config.redirectUri,
|
|
112
|
+
code_verifier: params.codeVerifier
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Rafraîchit un jeton expiré (un `refresh_token` n'est renvoyé que si le serveur en émet pour ce client). */
|
|
116
|
+
async refreshToken(refreshToken) {
|
|
117
|
+
return this.postToken({ grant_type: "refresh_token", refresh_token: refreshToken });
|
|
118
|
+
}
|
|
119
|
+
/** Révoque un jeton (déconnexion explicite). */
|
|
120
|
+
async revokeToken(token) {
|
|
121
|
+
const { response, data } = await this.send("/o/revoke_token/", {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
124
|
+
body: this.clientAuth({ token })
|
|
125
|
+
});
|
|
126
|
+
if (!response.ok) throw this.toError(response, data, "Failed to revoke token.");
|
|
127
|
+
}
|
|
128
|
+
/** Profil du porteur du jeton (`/o/userinfo/`) — les champs renvoyés dépendent des scopes accordés. */
|
|
129
|
+
async getUserInfo(accessToken) {
|
|
130
|
+
const { response, data } = await this.send("/o/userinfo/", {
|
|
131
|
+
method: "GET",
|
|
132
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
133
|
+
});
|
|
134
|
+
if (!response.ok) throw this.toError(response, data, "Failed to fetch user info.");
|
|
135
|
+
if (typeof data.sub !== "string" || !data.sub) {
|
|
136
|
+
throw new SanghoOAuthError("User info response has no `sub` claim.", { code: "invalid_response" });
|
|
137
|
+
}
|
|
138
|
+
return data;
|
|
139
|
+
}
|
|
140
|
+
// ── interne ────────────────────────────────────────────────────────────────
|
|
141
|
+
clientAuth(fields) {
|
|
142
|
+
const body = new URLSearchParams({ ...fields, client_id: this.config.clientId });
|
|
143
|
+
if (this.config.clientSecret) body.set("client_secret", this.config.clientSecret);
|
|
144
|
+
return body.toString();
|
|
145
|
+
}
|
|
146
|
+
async postToken(fields) {
|
|
147
|
+
const { response, data } = await this.send("/o/token/", {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
150
|
+
body: this.clientAuth(fields)
|
|
151
|
+
});
|
|
152
|
+
if (!response.ok) throw this.toError(response, data, "Token request failed.");
|
|
153
|
+
if (typeof data.access_token !== "string" || !data.access_token) {
|
|
154
|
+
throw new SanghoOAuthError("Token response has no `access_token`.", {
|
|
155
|
+
code: "invalid_response",
|
|
156
|
+
statusCode: response.status
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return data;
|
|
160
|
+
}
|
|
161
|
+
toError(response, data, fallback) {
|
|
162
|
+
const description = typeof data.error_description === "string" ? data.error_description : void 0;
|
|
163
|
+
return new SanghoOAuthError(description ?? fallback, {
|
|
164
|
+
code: typeof data.error === "string" ? data.error : void 0,
|
|
165
|
+
description,
|
|
166
|
+
statusCode: response.status
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async send(path, init) {
|
|
170
|
+
const controller = new AbortController();
|
|
171
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
172
|
+
try {
|
|
173
|
+
const response = await this.config.fetch(`${this.config.baseUrl}${path}`, {
|
|
174
|
+
...init,
|
|
175
|
+
headers: { Accept: "application/json", ...init.headers },
|
|
176
|
+
redirect: "error",
|
|
177
|
+
// un endpoint OAuth ne redirige jamais : ne pas suivre (et ne pas renvoyer un secret ailleurs)
|
|
178
|
+
signal: controller.signal
|
|
179
|
+
});
|
|
180
|
+
const parsed = await response.json().catch(() => ({}));
|
|
181
|
+
const data = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
182
|
+
return { response, data };
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err instanceof SanghoOAuthError) throw err;
|
|
185
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
186
|
+
throw new SanghoOAuthError(aborted ? "Sangho request timed out." : `Could not reach Sangho: ${err.message}`, {
|
|
187
|
+
code: aborted ? "timeout" : "network_error"
|
|
188
|
+
});
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
export {
|
|
195
|
+
SanghoOAuthClient,
|
|
196
|
+
SanghoOAuthError,
|
|
197
|
+
SanghoOAuthStateMismatchError,
|
|
198
|
+
generateCodeChallenge,
|
|
199
|
+
generateCodeVerifier,
|
|
200
|
+
generateState,
|
|
201
|
+
normalizeBaseUrl
|
|
202
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sanghosdk/oauth",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Se connecter avec Sangho — client OAuth2/PKCE (Authorization Code + PKCE), séparé du SDK de paiement.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"tsup": "^8.0.0",
|
|
30
|
+
"typescript": "^5.5.0",
|
|
31
|
+
"vitest": "^2.0.0"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/axon-libs/sangho-oauth-js.git"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"sangho",
|
|
43
|
+
"oauth2",
|
|
44
|
+
"pkce",
|
|
45
|
+
"login"
|
|
46
|
+
],
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"typecheck": "tsc --noEmit"
|
|
51
|
+
}
|
|
52
|
+
}
|