@liorian/oauth-kit 0.4.0-alpha.754ee48
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 +135 -0
- package/package.json +26 -0
- package/src/application/liorian-oauth.client.ts +94 -0
- package/src/domain/oauth-exception.ts +27 -0
- package/src/domain/oauth-scope.enum.ts +28 -0
- package/src/index.ts +25 -0
- package/src/infrastructure/crypto/signature.service.ts +64 -0
- package/src/infrastructure/http/http-client.ts +47 -0
- package/src/types/config.type.ts +56 -0
- package/src/types/session-token.type.ts +13 -0
- package/src/types/verification.types.ts +93 -0
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @liorian/oauth-kit
|
|
2
|
+
|
|
3
|
+
Client OAuth/SSO **côté serveur** pour les backends tiers (ex. `liorian-api-connect`,
|
|
4
|
+
`liorian-api-store`, applications externes).
|
|
5
|
+
|
|
6
|
+
Il permet de **vérifier et valider une session utilisateur** auprès de
|
|
7
|
+
`liorian-api-core` — appareil + session + consentement — et de récupérer les
|
|
8
|
+
informations de l'utilisateur (profil, e-mail, organisations, **rôles et
|
|
9
|
+
permissions**) **uniquement dans la limite de l'accord donné par l'utilisateur
|
|
10
|
+
lors de la connexion SSO** (scopes).
|
|
11
|
+
|
|
12
|
+
Le backend tiers ne possède que sa **clé API applicative** (clé publique + secret).
|
|
13
|
+
Il n'a jamais accès aux secrets, aux données critiques ni à la base de données de
|
|
14
|
+
`liorian-api-core` : **toute la sécurité est exécutée par `liorian-api-core`**.
|
|
15
|
+
|
|
16
|
+
## Pourquoi ce package ?
|
|
17
|
+
|
|
18
|
+
- `liorian-api-core` concentre toutes les vérifications : signature HMAC de la clé
|
|
19
|
+
API, validité du jeton JWT, validation de l'appareil (`UserDevice`), validation de
|
|
20
|
+
la session persistée (`Session`), statut de l'utilisateur, et **consentement
|
|
21
|
+
utilisateur** (scopes).
|
|
22
|
+
- Le backend tiers ne reçoit **aucune donnée non couverte par le consentement** :
|
|
23
|
+
pas de hash de mot de passe, pas de jetons, pas de clés, pas d'`auditId`, pas
|
|
24
|
+
d'informations internes.
|
|
25
|
+
- Le backend tiers ne partage **aucun secret critique** (JWT_SECRET, clé de
|
|
26
|
+
chiffrement, secrets de session).
|
|
27
|
+
|
|
28
|
+
## Workflow
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Frontend (app tierce) Backend tiers liorian-api-core
|
|
32
|
+
│ │ │
|
|
33
|
+
│ 1. login SSO (Autorisation Code + PKCE) │
|
|
34
|
+
│─────────────────────────────LIORIAN AUTH──────────────────▶│
|
|
35
|
+
│◀─────────────────── access_token (JWT) ──────────────────────│
|
|
36
|
+
│ │
|
|
37
|
+
│ 2. Bearer token envoyé au backend tiers │
|
|
38
|
+
│─────────────────────────────▶│ │
|
|
39
|
+
│ │ 3. verifySession(token) │
|
|
40
|
+
│ │ + clé API + signature HMAC │
|
|
41
|
+
│ │──────────────────────────────▶│
|
|
42
|
+
│ │ 4. valide l'appareil (UserDevice) │
|
|
43
|
+
│ │ 5. valide la session (Session) │
|
|
44
|
+
│ │ 6. vérifie le consentement │
|
|
45
|
+
│ │ 7. renvoie utilisateur + rôles │
|
|
46
|
+
│ │ + permissions (scopes) │
|
|
47
|
+
│ │◀───────────────────────────────│
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
bun add @liorian/oauth-kit # (workspace monorepo : bun add @liorian/oauth-kit -w ... )
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import {LiorianOAuthClient} from '@liorian/oauth-kit';
|
|
60
|
+
|
|
61
|
+
const oauth = new LiorianOAuthClient({
|
|
62
|
+
baseUrl: 'https://api.liorian.local/api', // ou LIORIAN_OAUTH_API_URL
|
|
63
|
+
apiKey: 'aaak-application-<hash>-sen', // ou LIORIAN_OAUTH_API_KEY
|
|
64
|
+
apiSecret: '<secret applicatif>', // ou LIORIAN_OAUTH_API_SECRET
|
|
65
|
+
clientId: 'mon-application', // ou LIORIAN_OAUTH_CLIENT_ID
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Variables d'environnement supportées :
|
|
70
|
+
|
|
71
|
+
| Variable | Rôle |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `LIORIAN_OAUTH_API_URL` | URL de base de `liorian-api-core` (défaut `https://localhost:5711/api`) |
|
|
74
|
+
| `LIORIAN_OAUTH_API_KEY` | Clé API publique de l'application (`aaak-...-sen`) |
|
|
75
|
+
| `LIORIAN_OAUTH_API_SECRET` | Secret de la clé API (signature HMAC) |
|
|
76
|
+
| `LIORIAN_OAUTH_CLIENT_ID` | Identifiant OAuth de l'application |
|
|
77
|
+
| `LIORIAN_OAUTH_AUTH_HOST` | Hôte de l'IdP Liorian Auth pour `buildAuthorizationUrl` |
|
|
78
|
+
|
|
79
|
+
## Vérifier une session
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import {OAuthVerificationError} from '@liorian/oauth-kit';
|
|
83
|
+
|
|
84
|
+
async function handler(req) {
|
|
85
|
+
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
86
|
+
try {
|
|
87
|
+
const result = await oauth.verifySession(token, {
|
|
88
|
+
userAgent: req.headers['user-agent'],
|
|
89
|
+
});
|
|
90
|
+
// result.user, result.roles, result.permissions, result.organizations…
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (e instanceof OAuthVerificationError) {
|
|
93
|
+
// 401 : session invalide / pas de consentement / clé API refusée
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Exemple de réponse
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"authenticated": true,
|
|
105
|
+
"scopes": ["openid", "profile", "email", "roles", "permissions"],
|
|
106
|
+
"user": {
|
|
107
|
+
"sub": "…",
|
|
108
|
+
"username": "…",
|
|
109
|
+
"name": {"firstname": "…", "lastname": "…"},
|
|
110
|
+
"email": "…"
|
|
111
|
+
},
|
|
112
|
+
"organizations": [{"id": "…", "name": "…", "isFounder": true, "role": "ROOT"}],
|
|
113
|
+
"roles": [{"id": "…", "name": "ROOT", "level": 99.99, "organizationId": "…"}],
|
|
114
|
+
"permissions": {"Customer": {"create": true, "read": true, "update": true, "delete": true}},
|
|
115
|
+
"device": {"id": "…", "userAgent": "…", "expiresAt": "…"},
|
|
116
|
+
"session": {"expiresAt": "…", "lastActiveAt": "…"}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Sécurité
|
|
121
|
+
|
|
122
|
+
- **Authentification de l'application** : challenge horodaté + signature HMAC-SHA256
|
|
123
|
+
(anti-rejeu, comparaison à temps constant côté API). Le secret ne circule jamais.
|
|
124
|
+
- **Validation de session complète** : JWT vérifié, `Session` persistée active et
|
|
125
|
+
non expirée, correspondance d'appareil (User-Agent), `UserDevice` non expiré,
|
|
126
|
+
utilisateur actif.
|
|
127
|
+
- **Consentement utilisateur obligatoire** : seuls les scopes accordés lors du SSO
|
|
128
|
+
(et autorisés pour l'application) sont renvoyés. Aucune donnée sensible
|
|
129
|
+
(mots de passe, jetons, internalités) n'est exposée.
|
|
130
|
+
- Le backend tiers ne reçoit **jamais** le `JWT_SECRET` ni les secrets de
|
|
131
|
+
`liorian-api-core`.
|
|
132
|
+
|
|
133
|
+
## Licence
|
|
134
|
+
|
|
135
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liorian/oauth-kit",
|
|
3
|
+
"version": "0.4.0-alpha.754ee48",
|
|
4
|
+
"description": "Client OAuth/SSO côté serveur pour les backends tiers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"module": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./*": "./src/*"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"typecheck": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "^1.1.0",
|
|
24
|
+
"typescript": "^6.0.3"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {DEFAULT_SCOPES} from '../domain/oauth-scope.enum';
|
|
2
|
+
import {OAuthClientError} from '../domain/oauth-exception';
|
|
3
|
+
import {signatureService} from '../infrastructure/crypto/signature.service';
|
|
4
|
+
import {HttpClient} from '../infrastructure/http/http-client';
|
|
5
|
+
import type {AuthorizationUrlParams, LiorianOAuthOptions, VerifySessionOptions} from '../types/config.type';
|
|
6
|
+
import type {OAuthVerifyRequest, OAuthVerificationResult} from '../types/verification.types';
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_OAUTH_API_URL = 'https://localhost:5711/api';
|
|
9
|
+
export const DEFAULT_OAUTH_TIMEOUT_MS = 15000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Client SSO/OAuth de `@liorian/oauth-kit` à utiliser CÔTÉ SERVEUR par un backend
|
|
13
|
+
* tiers (liorian-api-connect, liorian-api-store, applications externes…).
|
|
14
|
+
*
|
|
15
|
+
* Le backend tiers possède uniquement sa clé API applicative (clé publique +
|
|
16
|
+
* secret) : il ne détient jamais les secrets ni les données sensibles de
|
|
17
|
+
* `liorian-api-core`. Toute la sécurité (validation de l'appareil, de la
|
|
18
|
+
* session, du consentement utilisateur) est exécutée par `liorian-api-core`.
|
|
19
|
+
*/
|
|
20
|
+
export class LiorianOAuthClient {
|
|
21
|
+
private readonly baseUrl: string;
|
|
22
|
+
private readonly apiKey: string;
|
|
23
|
+
private readonly apiSecret: string;
|
|
24
|
+
private readonly clientId: string;
|
|
25
|
+
private readonly timeoutMs: number;
|
|
26
|
+
private readonly http: HttpClient;
|
|
27
|
+
|
|
28
|
+
constructor(options: LiorianOAuthOptions) {
|
|
29
|
+
this.baseUrl = (options.baseUrl ?? process.env['LIORIAN_OAUTH_API_URL'] ?? DEFAULT_OAUTH_API_URL).replace(/\/$/, '');
|
|
30
|
+
this.apiKey = options.apiKey ?? process.env['LIORIAN_OAUTH_API_KEY'] ?? '';
|
|
31
|
+
this.apiSecret = options.apiSecret ?? process.env['LIORIAN_OAUTH_API_SECRET'] ?? '';
|
|
32
|
+
this.clientId = options.clientId ?? process.env['LIORIAN_OAUTH_CLIENT_ID'] ?? '';
|
|
33
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_OAUTH_TIMEOUT_MS;
|
|
34
|
+
this.http = new HttpClient(this.baseUrl, this.timeoutMs);
|
|
35
|
+
|
|
36
|
+
if (!this.apiKey || !this.apiSecret) {
|
|
37
|
+
throw new OAuthClientError(
|
|
38
|
+
'`@liorian/oauth-kit` requiert `apiKey` et `apiSecret` (ou les variables ' +
|
|
39
|
+
'd’environnement LIORIAN_OAUTH_API_KEY / LIORIAN_OAUTH_API_SECRET).',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Vérifie et valide la session d'un utilisateur connecté via le SSO Liorian.
|
|
46
|
+
*
|
|
47
|
+
* Appelle `POST /oauth/verify` de liorian-api-core qui :
|
|
48
|
+
* 1. authentifie l'application tierce (clé API + signature HMAC du challenge) ;
|
|
49
|
+
* 2. valide l'appareil (UserDevice) et la session persistée (Session) ;
|
|
50
|
+
* 3. vérifie que l'utilisateur a consenti à partager ses données avec cette
|
|
51
|
+
* application ;
|
|
52
|
+
* 4. ne renvoie que les données couvertes par le consentement (scopes).
|
|
53
|
+
*
|
|
54
|
+
* @param token Jeton de session de l'utilisateur (Bearer JWT émis au SSO).
|
|
55
|
+
*/
|
|
56
|
+
async verifySession(token: string, opts: VerifySessionOptions = {}): Promise<OAuthVerificationResult> {
|
|
57
|
+
if (!token) {
|
|
58
|
+
throw new OAuthClientError('Le jeton de session à vérifier est requis.');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const challenge = signatureService.generateChallenge();
|
|
62
|
+
const signature = signatureService.sign(this.apiSecret, challenge);
|
|
63
|
+
|
|
64
|
+
const request: OAuthVerifyRequest = {
|
|
65
|
+
public_key: this.apiKey,
|
|
66
|
+
challenge,
|
|
67
|
+
signature,
|
|
68
|
+
token,
|
|
69
|
+
...(opts.userAgent ? {user_agent: opts.userAgent} : {}),
|
|
70
|
+
...(opts.ip ? {ip: opts.ip} : {}),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return this.http.post<OAuthVerificationResult>('/oauth/verify', request);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Construit l'URL d'autorisation SSO à destination du navigateur (frontend).
|
|
78
|
+
* L'application frontend redirige l'utilisateur vers l'IdP Liorian Auth.
|
|
79
|
+
*/
|
|
80
|
+
buildAuthorizationUrl(params: AuthorizationUrlParams): string {
|
|
81
|
+
const base = process.env['LIORIAN_OAUTH_AUTH_HOST'] ?? process.env['LIORIAN_AUTH_HOST'] ?? 'https://localhost:5050';
|
|
82
|
+
const url = new URL('/auth/sign-in', base.replace(/\/$/, ''));
|
|
83
|
+
url.searchParams.set('response_type', params.responseType ?? 'code');
|
|
84
|
+
url.searchParams.set('client_id', params.clientId);
|
|
85
|
+
url.searchParams.set('redirect_uri', params.redirectUri);
|
|
86
|
+
url.searchParams.set('scope', params.scope ?? DEFAULT_SCOPES);
|
|
87
|
+
url.searchParams.set('state', params.state ?? '');
|
|
88
|
+
if (params.codeChallenge) {
|
|
89
|
+
url.searchParams.set('code_challenge', params.codeChallenge);
|
|
90
|
+
url.searchParams.set('code_challenge_method', params.codeChallengeMethod ?? 'S256');
|
|
91
|
+
}
|
|
92
|
+
return url.toString();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Erreur technique du client `@liorian/oauth-kit` (réseau, timeout, réponse inattendue).
|
|
3
|
+
*/
|
|
4
|
+
export class OAuthClientError extends Error {
|
|
5
|
+
constructor(message: string, options?: {cause?: unknown}) {
|
|
6
|
+
super(message, options);
|
|
7
|
+
this.name = 'OAuthClientError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Erreur métier renvoyée par liorian-api-core lors de la vérification
|
|
13
|
+
* d'une session (clé API invalide, session expirée, consentement révoqué…).
|
|
14
|
+
*/
|
|
15
|
+
export class OAuthVerificationError extends Error {
|
|
16
|
+
/** Statut HTTP renvoyé par l'API. */
|
|
17
|
+
readonly status: number;
|
|
18
|
+
/** Code d'erreur optionnel renvoyé par l'API. */
|
|
19
|
+
readonly code?: string;
|
|
20
|
+
|
|
21
|
+
constructor(message: string, status = 401, code?: string) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'OAuthVerificationError';
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scopes OAuth (SSO Liorian) bien connus.
|
|
3
|
+
* Un backend tiers ne reçoit que les données couvertes par les scopes
|
|
4
|
+
* accordés par l'utilisateur lors de la connexion SSO (consentement).
|
|
5
|
+
*/
|
|
6
|
+
export enum OAuthScope {
|
|
7
|
+
/** Identifiant unique de session (OIDC `sub`). */
|
|
8
|
+
OPENID = 'openid',
|
|
9
|
+
/** Nom d'utilisateur, nom complet et avatar. */
|
|
10
|
+
PROFILE = 'profile',
|
|
11
|
+
/** Adresse e-mail principale. */
|
|
12
|
+
EMAIL = 'email',
|
|
13
|
+
/** Organisations auxquelles l'utilisateur appartient. */
|
|
14
|
+
ORGANIZATIONS = 'organizations',
|
|
15
|
+
/** Rôles de l'utilisateur (par organisation). */
|
|
16
|
+
ROLES = 'roles',
|
|
17
|
+
/** Permissions agrégées de l'utilisateur (par organisation). */
|
|
18
|
+
PERMISSIONS = 'permissions',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Scopes par défaut demandés lors d'une connexion SSO standard. */
|
|
22
|
+
export const DEFAULT_SCOPES = `${OAuthScope.OPENID} ${OAuthScope.PROFILE} ${OAuthScope.EMAIL}`;
|
|
23
|
+
|
|
24
|
+
/** Durée de validité maximale acceptée pour le challenge de signature (anti-rejeu). */
|
|
25
|
+
export const CHALLENGE_TTL_MS = 2 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** Séparateur utilisé entre l'horodatage et le nonce dans un challenge. */
|
|
28
|
+
export const CHALLENGE_SEPARATOR = '.';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
LiorianOAuthClient,
|
|
3
|
+
DEFAULT_OAUTH_API_URL,
|
|
4
|
+
DEFAULT_OAUTH_TIMEOUT_MS,
|
|
5
|
+
} from './application/liorian-oauth.client';
|
|
6
|
+
export {OAuthScope, DEFAULT_SCOPES, CHALLENGE_TTL_MS, CHALLENGE_SEPARATOR} from './domain/oauth-scope.enum';
|
|
7
|
+
export {OAuthClientError, OAuthVerificationError} from './domain/oauth-exception';
|
|
8
|
+
export {SignatureService, signatureService} from './infrastructure/crypto/signature.service';
|
|
9
|
+
export {HttpClient} from './infrastructure/http/http-client';
|
|
10
|
+
export type {
|
|
11
|
+
LiorianOAuthOptions,
|
|
12
|
+
AuthorizationUrlParams,
|
|
13
|
+
VerifySessionOptions,
|
|
14
|
+
} from './types/config.type';
|
|
15
|
+
export type {SessionTokenPayload} from './types/session-token.type';
|
|
16
|
+
export type {
|
|
17
|
+
OAuthVerifyRequest,
|
|
18
|
+
OAuthVerificationResult,
|
|
19
|
+
OAuthVerifiedUser,
|
|
20
|
+
OAuthVerifiedOrganization,
|
|
21
|
+
OAuthVerifiedRole,
|
|
22
|
+
OAuthPermissionCapability,
|
|
23
|
+
OAuthVerifiedDevice,
|
|
24
|
+
OAuthVerifiedSession,
|
|
25
|
+
} from './types/verification.types';
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {createHmac, randomBytes, timingSafeEqual} from 'node:crypto';
|
|
2
|
+
import {CHALLENGE_SEPARATOR, CHALLENGE_TTL_MS} from '../../domain/oauth-scope.enum';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Service de signature HMAC-SHA256 utilisé pour authentifier les appels du
|
|
6
|
+
* backend tiers auprès de liorian-api-core.
|
|
7
|
+
*
|
|
8
|
+
* Le challenge est un nonce horodaté (`<timestamp>. <nonce>`). Sa signature
|
|
9
|
+
* avec le secret applicatif prouve la possession de la clé API sans jamais
|
|
10
|
+
* transmettre le secret, et sa vérification temporelle limite le rejeu.
|
|
11
|
+
*/
|
|
12
|
+
export class SignatureService {
|
|
13
|
+
/**
|
|
14
|
+
* Génère un challenge frais : `<timestamp>.<nonce base64url>`.
|
|
15
|
+
*/
|
|
16
|
+
generateChallenge(now: number = Date.now()): string {
|
|
17
|
+
const nonce = randomBytes(24).toString('base64url');
|
|
18
|
+
return `${now}${CHALLENGE_SEPARATOR}${nonce}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Signe le challenge avec le secret applicatif (HMAC-SHA256, base64).
|
|
23
|
+
* Algorithme identique à `CryptoService.hmacBase64` de `@liorian/api-resources`.
|
|
24
|
+
*/
|
|
25
|
+
sign(secret: string, challenge: string): string {
|
|
26
|
+
return createHmac('sha256', secret).update(challenge).digest('base64');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Vérifie qu'un challenge est encore frais (anti-rejeu).
|
|
31
|
+
* Un challenge volé reste valable uniquement pendant `CHALLENGE_TTL_MS`.
|
|
32
|
+
*/
|
|
33
|
+
isFresh(challenge: string, now: number = Date.now(), ttlMs: number = CHALLENGE_TTL_MS): boolean {
|
|
34
|
+
const separatorIndex = challenge.indexOf(CHALLENGE_SEPARATOR);
|
|
35
|
+
if (separatorIndex <= 0) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const timestamp = Number(challenge.slice(0, separatorIndex));
|
|
39
|
+
if (!Number.isFinite(timestamp)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const delta = now - timestamp;
|
|
43
|
+
return delta >= 0 && delta <= ttlMs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Compare deux signatures à temps constant (anti attaques par mesure du temps).
|
|
48
|
+
*/
|
|
49
|
+
safeEqual(expected: string, actual: string): boolean {
|
|
50
|
+
if (expected.length !== actual.length) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return timingSafeEqual(
|
|
55
|
+
Buffer.from(expected, 'utf8'),
|
|
56
|
+
Buffer.from(actual, 'utf8'),
|
|
57
|
+
);
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const signatureService = new SignatureService();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {OAuthClientError, OAuthVerificationError} from '../../domain/oauth-exception';
|
|
2
|
+
import type {RaitonErrorPayload} from '../../types/config.type';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Client HTTP minimal (fetch natif) vers liorian-api-core.
|
|
6
|
+
* Gère le décodage de `RaitonResponses` (`{message, data, status}`) et lève
|
|
7
|
+
* des erreurs typées.
|
|
8
|
+
*/
|
|
9
|
+
export class HttpClient {
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly baseUrl: string,
|
|
12
|
+
private readonly timeoutMs: number = 15000,
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
async post<T>(path: string, body: unknown): Promise<T> {
|
|
16
|
+
const controller = new AbortController();
|
|
17
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: {'content-type': 'application/json'},
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
signal: controller.signal,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const payload = await response.json().catch(() => null) as (RaitonErrorPayload & {status?: number}) | null;
|
|
28
|
+
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
const message = payload?.message || `Erreur HTTP ${response.status}`;
|
|
31
|
+
throw new OAuthVerificationError(message, response.status, payload?.code);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (payload?.data ?? payload) as T;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error instanceof OAuthVerificationError) {
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
40
|
+
throw new OAuthClientError(`Délai d'attente dépassé pour ${path}`, {cause: error});
|
|
41
|
+
}
|
|
42
|
+
throw new OAuthClientError(`Appel réseau échoué pour ${path}`, {cause: error});
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {OAuthScope} from "../domain/oauth-scope.enum";
|
|
2
|
+
|
|
3
|
+
/** Options de configuration du client `@liorian/oauth-kit`. */
|
|
4
|
+
export interface LiorianOAuthOptions {
|
|
5
|
+
/**
|
|
6
|
+
* URL de base de liorian-api-core (suffixe `/api` inclus).
|
|
7
|
+
* Défaut : `https://localhost:5711/api`.
|
|
8
|
+
*/
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Clé API publique de l'application enregistrée dans liorian-api-core
|
|
12
|
+
* (format `aaak-${type}-${hash}-sen`).
|
|
13
|
+
*/
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Secret associé à la clé API (jamais exposé côté client/frontend).
|
|
17
|
+
* Utilisé uniquement pour signer le challenge HMAC.
|
|
18
|
+
*/
|
|
19
|
+
apiSecret?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Identifiant OAuth de l'application (`clientId`). Sert à construire
|
|
22
|
+
* l'URL d'autorisation destinée au navigateur.
|
|
23
|
+
*/
|
|
24
|
+
clientId?: string;
|
|
25
|
+
/** Timeout des appels HTTP (millisecondes). Défaut : 15000. */
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Paramètres de construction de l'URL d'autorisation SSO (frontend). */
|
|
30
|
+
export interface AuthorizationUrlParams {
|
|
31
|
+
clientId: string;
|
|
32
|
+
redirectUri: string;
|
|
33
|
+
scope?: string;
|
|
34
|
+
state?: string;
|
|
35
|
+
codeChallenge?: string;
|
|
36
|
+
codeChallengeMethod?: 'S256' | 'plain';
|
|
37
|
+
responseType?: 'code';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Forme du message d'erreur standardisé de RaitonResponses. */
|
|
41
|
+
export interface RaitonErrorPayload {
|
|
42
|
+
message?: string;
|
|
43
|
+
code?: string;
|
|
44
|
+
data?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface VerifySessionOptions {
|
|
48
|
+
/**
|
|
49
|
+
* User-Agent observé par le backend tiers au moment de la requête
|
|
50
|
+
* utilisateur. Permet à liorian-api-core de faire correspondre
|
|
51
|
+
* l'empreinte de l'appareil attendue avec la session.
|
|
52
|
+
*/
|
|
53
|
+
userAgent?: string;
|
|
54
|
+
/** Adresse IP observée par le backend tiers (journalisable par l'API). */
|
|
55
|
+
ip?: string;
|
|
56
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Claims portés par le jeton de session JWT émis par liorian-api-core au sign-in SSO. */
|
|
2
|
+
export interface SessionTokenPayload {
|
|
3
|
+
id: string;
|
|
4
|
+
username?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
device?: string;
|
|
7
|
+
userAgent?: string;
|
|
8
|
+
sub?: string;
|
|
9
|
+
iss?: string;
|
|
10
|
+
aud?: string;
|
|
11
|
+
iat?: number;
|
|
12
|
+
exp?: number;
|
|
13
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {OAuthScope} from "../domain/oauth-scope.enum";
|
|
2
|
+
|
|
3
|
+
/** Corps de la requête envoyé à `POST /oauth/verify` de liorian-api-core. */
|
|
4
|
+
export interface OAuthVerifyRequest {
|
|
5
|
+
/** Clé API publique de l'application tierce (format `aaak-...-sen`). */
|
|
6
|
+
public_key: string;
|
|
7
|
+
/** Challenge nonce horodaté (`<ts>.<nonce>`) signé en HMAC-SHA256. */
|
|
8
|
+
challenge: string;
|
|
9
|
+
/** Signature HMAC-SHA256 (base64) du challenge avec le secret applicatif. */
|
|
10
|
+
signature: string;
|
|
11
|
+
/** Jeton de session de l'utilisateur (Bearer JWT émis au SSO). */
|
|
12
|
+
token: string;
|
|
13
|
+
/** User-Agent observé par le backend tiers (validation de l'appareil). */
|
|
14
|
+
user_agent?: string;
|
|
15
|
+
/** Adresse IP observée par le backend tiers. */
|
|
16
|
+
ip?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Identité minimale de l'utilisateur, filtrée par les scopes accordés. */
|
|
20
|
+
export interface OAuthVerifiedUser {
|
|
21
|
+
/** Identifiant unique (OIDC `sub`), accordé par le scope `openid`. */
|
|
22
|
+
sub: string;
|
|
23
|
+
/** Alias technique, renvoyé uniquement si le scope `profile` est accordé. */
|
|
24
|
+
username?: string;
|
|
25
|
+
/** Nom complet (prénom + nom), accordé par le scope `profile`. */
|
|
26
|
+
name?: {
|
|
27
|
+
firstname?: string;
|
|
28
|
+
lastname?: string;
|
|
29
|
+
fullname?: string;
|
|
30
|
+
};
|
|
31
|
+
/** Adresse e-mail, renvoyée uniquement si le scope `email` est accordé. */
|
|
32
|
+
email?: string;
|
|
33
|
+
/** Avatar public, renvoyé uniquement si le scope `profile` est accordé. */
|
|
34
|
+
avatar?: {
|
|
35
|
+
id?: string;
|
|
36
|
+
filename?: string;
|
|
37
|
+
type?: string;
|
|
38
|
+
label?: string;
|
|
39
|
+
isPublic?: boolean;
|
|
40
|
+
createdAt?: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Organisation membre, renvoyée uniquement si le scope `organizations` est accordé. */
|
|
45
|
+
export interface OAuthVerifiedOrganization {
|
|
46
|
+
id?: string;
|
|
47
|
+
name?: string;
|
|
48
|
+
isFounder?: boolean;
|
|
49
|
+
/** Nom du rôle dominant dans l'organisation si le scope `roles` est aussi accordé. */
|
|
50
|
+
role?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Rôle de l'utilisateur dans une organisation (scope `roles`). */
|
|
54
|
+
export interface OAuthVerifiedRole {
|
|
55
|
+
id?: string;
|
|
56
|
+
name: string;
|
|
57
|
+
level?: number;
|
|
58
|
+
organizationId?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Capacité CRUD d'un domaine (scope `permissions`). */
|
|
62
|
+
export interface OAuthPermissionCapability {
|
|
63
|
+
create: boolean;
|
|
64
|
+
read: boolean;
|
|
65
|
+
update: boolean;
|
|
66
|
+
delete: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Informations sur l'appareil validé (jamais le jeton associé). */
|
|
70
|
+
export interface OAuthVerifiedDevice {
|
|
71
|
+
id?: string;
|
|
72
|
+
userAgent?: string;
|
|
73
|
+
expiresAt?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Informations sur la session validée (jamais le jeton associé). */
|
|
77
|
+
export interface OAuthVerifiedSession {
|
|
78
|
+
expiresAt?: string;
|
|
79
|
+
lastActiveAt?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Résultat de la vérification d'une session par liorian-api-core. */
|
|
83
|
+
export interface OAuthVerificationResult {
|
|
84
|
+
authenticated: true;
|
|
85
|
+
/** Scopes effectivement accordés par le consentement (intersection client × consentement). */
|
|
86
|
+
scopes: OAuthScope[];
|
|
87
|
+
user: OAuthVerifiedUser;
|
|
88
|
+
organizations: OAuthVerifiedOrganization[];
|
|
89
|
+
roles: OAuthVerifiedRole[];
|
|
90
|
+
permissions: Record<string, OAuthPermissionCapability>;
|
|
91
|
+
device?: OAuthVerifiedDevice;
|
|
92
|
+
session?: OAuthVerifiedSession;
|
|
93
|
+
}
|