@ascencia/id-core 0.1.0-rc.1
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 +52 -0
- package/package.json +34 -0
- package/src/client.ts +657 -0
- package/src/discovery.ts +170 -0
- package/src/errors.ts +32 -0
- package/src/index.ts +29 -0
- package/src/pkce.ts +84 -0
- package/src/storage.ts +103 -0
- package/src/types.ts +253 -0
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Découverte OIDC.
|
|
3
|
+
*
|
|
4
|
+
* Le document est mis en cache par issuer, au niveau du module et non de
|
|
5
|
+
* l'instance : plusieurs `AscenciaClient` sur la même page (cas du widget
|
|
6
|
+
* cohabitant avec un SDK) ne doivent pas déclencher plusieurs requêtes.
|
|
7
|
+
*
|
|
8
|
+
* Voir docs/05-API.md §1.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { AscenciaError } from './errors';
|
|
12
|
+
import type { DiscoveryDocument, FetchLike } from './types';
|
|
13
|
+
|
|
14
|
+
/** Le serveur annonce `Cache-Control: max-age=3600` : on s'aligne dessus. */
|
|
15
|
+
export const DEFAULT_DISCOVERY_TTL_MS = 3_600_000;
|
|
16
|
+
|
|
17
|
+
export const DISCOVERY_PATH = '/.well-known/openid-configuration';
|
|
18
|
+
|
|
19
|
+
interface CacheEntry {
|
|
20
|
+
expiresAt: number;
|
|
21
|
+
document: DiscoveryDocument;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const cache = new Map<string, CacheEntry>();
|
|
25
|
+
const inFlight = new Map<string, Promise<DiscoveryDocument>>();
|
|
26
|
+
|
|
27
|
+
/** Le slash final est signifiant dans une comparaison d'issuer : on l'enlève partout. */
|
|
28
|
+
export function normalizeIssuer(issuer: string): string {
|
|
29
|
+
return issuer.replace(/\/+$/, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function discoveryUrl(issuer: string): string {
|
|
33
|
+
return `${normalizeIssuer(issuer)}${DISCOVERY_PATH}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DiscoverOptions {
|
|
37
|
+
fetch?: FetchLike;
|
|
38
|
+
ttlMs?: number;
|
|
39
|
+
/** Ignore le cache et refait la requête. */
|
|
40
|
+
force?: boolean;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertUsable(issuer: string, value: unknown): DiscoveryDocument {
|
|
45
|
+
if (typeof value !== 'object' || value === null) {
|
|
46
|
+
throw new AscenciaError('invalid_discovery', "Le document de découverte n'est pas un objet.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const document = value as DiscoveryDocument;
|
|
50
|
+
|
|
51
|
+
// Contre-mesure au « mix-up » (RFC 8414 §3.3) : un document servi par un
|
|
52
|
+
// hôte détourné pointerait ses endpoints ailleurs tout en gardant l'air
|
|
53
|
+
// légitime. L'issuer annoncé doit être exactement celui qu'on a demandé.
|
|
54
|
+
if (normalizeIssuer(String(document.issuer ?? '')) !== issuer) {
|
|
55
|
+
throw new AscenciaError(
|
|
56
|
+
'issuer_mismatch',
|
|
57
|
+
`Le document annonce l'issuer « ${String(document.issuer)} » alors que « ${issuer} » était attendu.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const field of ['authorization_endpoint', 'token_endpoint'] as const) {
|
|
62
|
+
if (typeof document[field] !== 'string' || document[field].length === 0) {
|
|
63
|
+
throw new AscenciaError('invalid_discovery', `Le document ne déclare pas \`${field}\`.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const methods = document.code_challenge_methods_supported;
|
|
68
|
+
if (Array.isArray(methods) && !methods.includes('S256')) {
|
|
69
|
+
throw new AscenciaError(
|
|
70
|
+
'pkce_unsupported',
|
|
71
|
+
"Le serveur n'annonce pas `S256`. Ce client refuse de se rabattre sur `plain`.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return document;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function discover(
|
|
79
|
+
issuer: string,
|
|
80
|
+
options: DiscoverOptions = {},
|
|
81
|
+
): Promise<DiscoveryDocument> {
|
|
82
|
+
const key = normalizeIssuer(issuer);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
|
|
85
|
+
if (!options.force) {
|
|
86
|
+
const hit = cache.get(key);
|
|
87
|
+
if (hit && hit.expiresAt > now) return hit.document;
|
|
88
|
+
|
|
89
|
+
// Deux composants montés en même temps demandent le document au même
|
|
90
|
+
// instant : ils partagent la requête au lieu d'en lancer deux.
|
|
91
|
+
const pending = inFlight.get(key);
|
|
92
|
+
if (pending) return pending;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const request = fetchDocument(key, options)
|
|
96
|
+
.then((document) => {
|
|
97
|
+
cache.set(key, {
|
|
98
|
+
document,
|
|
99
|
+
expiresAt: Date.now() + (options.ttlMs ?? DEFAULT_DISCOVERY_TTL_MS),
|
|
100
|
+
});
|
|
101
|
+
return document;
|
|
102
|
+
})
|
|
103
|
+
.finally(() => {
|
|
104
|
+
inFlight.delete(key);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
inFlight.set(key, request);
|
|
108
|
+
return request;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function fetchDocument(issuer: string, options: DiscoverOptions): Promise<DiscoveryDocument> {
|
|
112
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
113
|
+
if (typeof doFetch !== 'function') {
|
|
114
|
+
throw new AscenciaError('fetch_unavailable', '`fetch` est indisponible dans cet environnement.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let response: Response;
|
|
118
|
+
try {
|
|
119
|
+
response = await doFetch(discoveryUrl(issuer), {
|
|
120
|
+
headers: { accept: 'application/json' },
|
|
121
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
122
|
+
});
|
|
123
|
+
} catch (cause) {
|
|
124
|
+
throw new AscenciaError('network_error', `Découverte OIDC injoignable sur ${issuer}.`, {
|
|
125
|
+
cause,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
throw new AscenciaError(
|
|
131
|
+
'discovery_failed',
|
|
132
|
+
`La découverte OIDC a répondu ${response.status}.`,
|
|
133
|
+
{ status: response.status },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let body: unknown;
|
|
138
|
+
try {
|
|
139
|
+
body = await response.json();
|
|
140
|
+
} catch (cause) {
|
|
141
|
+
throw new AscenciaError('invalid_discovery', 'Réponse de découverte illisible.', { cause });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return assertUsable(issuer, body);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Sans argument, vide tout le cache. Sert aux tests et au changement d'issuer. */
|
|
148
|
+
export function clearDiscoveryCache(issuer?: string): void {
|
|
149
|
+
if (issuer === undefined) {
|
|
150
|
+
cache.clear();
|
|
151
|
+
inFlight.clear();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const key = normalizeIssuer(issuer);
|
|
155
|
+
cache.delete(key);
|
|
156
|
+
inFlight.delete(key);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Alimente le cache sans requête réseau. Permet à un rendu serveur de passer
|
|
161
|
+
* le document au client, et aux tests de se passer de `fetch`.
|
|
162
|
+
*/
|
|
163
|
+
export function seedDiscoveryCache(
|
|
164
|
+
issuer: string,
|
|
165
|
+
document: DiscoveryDocument,
|
|
166
|
+
ttlMs = DEFAULT_DISCOVERY_TTL_MS,
|
|
167
|
+
): void {
|
|
168
|
+
const key = normalizeIssuer(issuer);
|
|
169
|
+
cache.set(key, { document: assertUsable(key, document), expiresAt: Date.now() + ttlMs });
|
|
170
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Un seul type d'erreur pour tout le SDK : l'application n'a qu'un `catch` à
|
|
3
|
+
* écrire, et le `code` reste stable même si le message change de formulation
|
|
4
|
+
* ou de langue.
|
|
5
|
+
*/
|
|
6
|
+
export class AscenciaError extends Error {
|
|
7
|
+
/** Code machine : `invalid_state`, `network_error`, un code OAuth du serveur… */
|
|
8
|
+
readonly code: string;
|
|
9
|
+
/** `error_description` renvoyée par l'IdP, quand il y en a une. */
|
|
10
|
+
readonly description: string | null;
|
|
11
|
+
/** Statut HTTP à l'origine de l'erreur, `null` si elle est purement locale. */
|
|
12
|
+
readonly status: number | null;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
code: string,
|
|
16
|
+
message: string,
|
|
17
|
+
options: { description?: string | null; status?: number | null; cause?: unknown } = {},
|
|
18
|
+
) {
|
|
19
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
20
|
+
this.name = 'AscenciaError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.description = options.description ?? null;
|
|
23
|
+
this.status = options.status ?? null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Normalise n'importe quoi de jeté en `AscenciaError`, sans perdre l'original. */
|
|
28
|
+
export function toAscenciaError(value: unknown, fallbackCode = 'unknown_error'): AscenciaError {
|
|
29
|
+
if (value instanceof AscenciaError) return value;
|
|
30
|
+
const message = value instanceof Error ? value.message : String(value);
|
|
31
|
+
return new AscenciaError(fallbackCode, message, { cause: value });
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export * from './types';
|
|
2
|
+
export { AscenciaError, toAscenciaError } from './errors';
|
|
3
|
+
export {
|
|
4
|
+
CODE_CHALLENGE_METHOD,
|
|
5
|
+
base64UrlEncode,
|
|
6
|
+
challengeFromVerifier,
|
|
7
|
+
generateNonce,
|
|
8
|
+
generateState,
|
|
9
|
+
generateVerifier,
|
|
10
|
+
randomBytes,
|
|
11
|
+
randomString,
|
|
12
|
+
} from './pkce';
|
|
13
|
+
export {
|
|
14
|
+
DEFAULT_DISCOVERY_TTL_MS,
|
|
15
|
+
DISCOVERY_PATH,
|
|
16
|
+
clearDiscoveryCache,
|
|
17
|
+
discover,
|
|
18
|
+
discoveryUrl,
|
|
19
|
+
normalizeIssuer,
|
|
20
|
+
seedDiscoveryCache,
|
|
21
|
+
type DiscoverOptions,
|
|
22
|
+
} from './discovery';
|
|
23
|
+
export { isStorageAdapter, memoryStorage, resolveStorage, webStorage } from './storage';
|
|
24
|
+
export { AscenciaClient, createClient } from './client';
|
|
25
|
+
|
|
26
|
+
// Le matching de permissions est celui du serveur, à la virgule près : un
|
|
27
|
+
// `can()` côté client qui divergerait du verdict côté serveur serait pire
|
|
28
|
+
// qu'inutile. Réexporté pour que les SDK n'aient pas à dépendre du RBAC.
|
|
29
|
+
export { evaluate, matches } from '@ascencia/id-rbac';
|
package/src/pkce.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PKCE (RFC 7636) sur WebCrypto seul.
|
|
3
|
+
*
|
|
4
|
+
* Uniquement `S256` : `plain` transmet le verifier en clair dans l'URL
|
|
5
|
+
* d'autorisation, ce qui annule exactement la protection qu'on cherche. L'IdP
|
|
6
|
+
* ne l'annonce pas et ne l'accepte pas non plus.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { AscenciaError } from './errors';
|
|
10
|
+
|
|
11
|
+
export const CODE_CHALLENGE_METHOD = 'S256';
|
|
12
|
+
|
|
13
|
+
/** 48 octets = 64 caractères base64url, bien au-dessus du minimum de 43 du RFC. */
|
|
14
|
+
const DEFAULT_VERIFIER_BYTES = 48;
|
|
15
|
+
|
|
16
|
+
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
17
|
+
|
|
18
|
+
function getCrypto(): Crypto {
|
|
19
|
+
const impl = globalThis.crypto;
|
|
20
|
+
if (!impl?.subtle) {
|
|
21
|
+
throw new AscenciaError(
|
|
22
|
+
'crypto_unavailable',
|
|
23
|
+
"WebCrypto est indisponible. En navigateur, il exige un contexte sécurisé (HTTPS ou localhost) ; sous Node, la version 20 ou plus.",
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return impl;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Encodage base64url **sans padding**, écrit à la main : `btoa` n'existe pas
|
|
31
|
+
* partout et `Buffer` n'existe pas en navigateur. Trente lignes valent mieux
|
|
32
|
+
* qu'une dépendance ou qu'un polyfill conditionnel.
|
|
33
|
+
*/
|
|
34
|
+
export function base64UrlEncode(bytes: Uint8Array): string {
|
|
35
|
+
let out = '';
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
38
|
+
const b0 = bytes[i] ?? 0;
|
|
39
|
+
const b1 = bytes[i + 1];
|
|
40
|
+
const b2 = bytes[i + 2];
|
|
41
|
+
|
|
42
|
+
out += BASE64URL_ALPHABET.charAt(b0 >> 2);
|
|
43
|
+
out += BASE64URL_ALPHABET.charAt(((b0 & 0b11) << 4) | ((b1 ?? 0) >> 4));
|
|
44
|
+
if (b1 === undefined) break;
|
|
45
|
+
|
|
46
|
+
out += BASE64URL_ALPHABET.charAt(((b1 & 0b1111) << 2) | ((b2 ?? 0) >> 6));
|
|
47
|
+
if (b2 === undefined) break;
|
|
48
|
+
|
|
49
|
+
out += BASE64URL_ALPHABET.charAt(b2 & 0b111111);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function randomBytes(length: number): Uint8Array {
|
|
56
|
+
return getCrypto().getRandomValues(new Uint8Array(length));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Chaîne aléatoire base64url, utilisable telle quelle dans une URL. */
|
|
60
|
+
export function randomString(byteLength = 32): string {
|
|
61
|
+
return base64UrlEncode(randomBytes(byteLength));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Le verifier n'est composé que de caractères « unreserved » du RFC 7636, ce
|
|
66
|
+
* que garantit l'alphabet base64url.
|
|
67
|
+
*/
|
|
68
|
+
export function generateVerifier(byteLength = DEFAULT_VERIFIER_BYTES): string {
|
|
69
|
+
return base64UrlEncode(randomBytes(byteLength));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function challengeFromVerifier(verifier: string): Promise<string> {
|
|
73
|
+
const digest = await getCrypto().subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
|
74
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Le `state` doit porter au moins 16 octets d'entropie (docs/05-API.md §2). */
|
|
78
|
+
export function generateState(): string {
|
|
79
|
+
return randomString(32);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function generateNonce(): string {
|
|
83
|
+
return randomString(32);
|
|
84
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptateurs de stockage.
|
|
3
|
+
*
|
|
4
|
+
* Règle absolue : **rien ne lève ici**. Safari en navigation privée, Firefox
|
|
5
|
+
* avec `dom.storage.enabled=false` et un iframe tiers sans accès au stockage
|
|
6
|
+
* jettent une `SecurityError` dès le premier accès, parfois dès la simple
|
|
7
|
+
* lecture de `window.localStorage`. Une exception à cet endroit ferait tomber
|
|
8
|
+
* la page hôte alors que la seule conséquence raisonnable est de perdre la
|
|
9
|
+
* persistance : on retombe silencieusement en mémoire.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { StorageAdapter, StoragePreference } from './types';
|
|
13
|
+
|
|
14
|
+
export function memoryStorage(): StorageAdapter {
|
|
15
|
+
const map = new Map<string, string>();
|
|
16
|
+
return {
|
|
17
|
+
get: (key) => map.get(key) ?? null,
|
|
18
|
+
set: (key, value) => {
|
|
19
|
+
map.set(key, value);
|
|
20
|
+
},
|
|
21
|
+
remove: (key) => {
|
|
22
|
+
map.delete(key);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isStorageAdapter(value: unknown): value is StorageAdapter {
|
|
28
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
29
|
+
const candidate = value as Partial<StorageAdapter>;
|
|
30
|
+
return (
|
|
31
|
+
typeof candidate.get === 'function' &&
|
|
32
|
+
typeof candidate.set === 'function' &&
|
|
33
|
+
typeof candidate.remove === 'function'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* La lecture de la propriété elle-même peut lever, d'où le `try` autour de
|
|
39
|
+
* l'accès et pas seulement autour des appels.
|
|
40
|
+
*/
|
|
41
|
+
function pickWebStorage(kind: 'session' | 'local'): Storage | null {
|
|
42
|
+
try {
|
|
43
|
+
const store = kind === 'session' ? globalThis.sessionStorage : globalThis.localStorage;
|
|
44
|
+
if (!store) return null;
|
|
45
|
+
|
|
46
|
+
// Un stockage plein ou en lecture seule ne se révèle qu'à l'écriture :
|
|
47
|
+
// on paie une écriture sonde plutôt qu'un échec silencieux plus tard.
|
|
48
|
+
const probe = '__ascencia_probe__';
|
|
49
|
+
store.setItem(probe, '1');
|
|
50
|
+
store.removeItem(probe);
|
|
51
|
+
return store;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stockage web avec repli mémoire. Le repli est aussi un miroir : si le quota
|
|
59
|
+
* saute en cours de route, les clés déjà écrites restent lisibles pour la
|
|
60
|
+
* durée de vie de la page, et une transaction PKCE en cours n'est pas perdue.
|
|
61
|
+
*/
|
|
62
|
+
export function webStorage(kind: 'session' | 'local'): StorageAdapter {
|
|
63
|
+
const store = pickWebStorage(kind);
|
|
64
|
+
const fallback = memoryStorage();
|
|
65
|
+
if (!store) return fallback;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
get: (key) => {
|
|
69
|
+
try {
|
|
70
|
+
return store.getItem(key) ?? fallback.get(key);
|
|
71
|
+
} catch {
|
|
72
|
+
return fallback.get(key);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
set: (key, value) => {
|
|
76
|
+
fallback.set(key, value);
|
|
77
|
+
try {
|
|
78
|
+
store.setItem(key, value);
|
|
79
|
+
} catch {
|
|
80
|
+
// Quota dépassé ou permission retirée : le miroir mémoire suffit.
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
remove: (key) => {
|
|
84
|
+
fallback.remove(key);
|
|
85
|
+
try {
|
|
86
|
+
store.removeItem(key);
|
|
87
|
+
} catch {
|
|
88
|
+
// Rien à faire : la clé est de toute façon inaccessible.
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveStorage(
|
|
95
|
+
preference: StoragePreference | undefined,
|
|
96
|
+
fallback: StoragePreference,
|
|
97
|
+
): StorageAdapter {
|
|
98
|
+
const choice = preference ?? fallback;
|
|
99
|
+
if (isStorageAdapter(choice)) return choice;
|
|
100
|
+
if (choice === 'session') return webStorage('session');
|
|
101
|
+
if (choice === 'local') return webStorage('local');
|
|
102
|
+
return memoryStorage();
|
|
103
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types publics du client OIDC d'Ascencia ID.
|
|
3
|
+
*
|
|
4
|
+
* Ce package est le socle commun des SDK React / Svelte et du widget : il ne
|
|
5
|
+
* connaît ni le DOM ni aucun framework, et n'a aucune dépendance runtime.
|
|
6
|
+
* Tout ce qui touche à la popup, aux composants ou aux stores vit un cran
|
|
7
|
+
* au-dessus.
|
|
8
|
+
*
|
|
9
|
+
* Voir docs/05-API.md et docs/06-INTEGRATION-SDK.md
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { AscenciaError } from './errors';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Signature minimale de `fetch`. On ne référence pas `typeof fetch` pour que le
|
|
16
|
+
* package se type identiquement sous `lib: DOM` et sous les types de Node.
|
|
17
|
+
*/
|
|
18
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stockage clé/valeur synchrone.
|
|
22
|
+
*
|
|
23
|
+
* Aucune méthode ne doit lever : en navigation privée ou avec les cookies
|
|
24
|
+
* tiers bloqués, `localStorage` jette au premier accès et une exception ici
|
|
25
|
+
* casserait toute la page hôte. Voir `storage.ts`.
|
|
26
|
+
*/
|
|
27
|
+
export interface StorageAdapter {
|
|
28
|
+
get(key: string): string | null;
|
|
29
|
+
set(key: string, value: string): void;
|
|
30
|
+
remove(key: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `'memory'` est le défaut pour les jetons.
|
|
35
|
+
*
|
|
36
|
+
* `'local'` survit à la fermeture de l'onglet mais expose le jeton à tout XSS
|
|
37
|
+
* de la page : c'est un choix explicite du développeur, pas une valeur par
|
|
38
|
+
* défaut. Voir docs/08-SECURITY.md.
|
|
39
|
+
*/
|
|
40
|
+
export type StoragePreference = 'memory' | 'session' | 'local' | StorageAdapter;
|
|
41
|
+
|
|
42
|
+
export interface AscenciaConfig {
|
|
43
|
+
/** Racine de l'IdP, sans slash final, ex. `https://id.ascencia.re`. */
|
|
44
|
+
issuer: string;
|
|
45
|
+
clientId: string;
|
|
46
|
+
redirectUri: string;
|
|
47
|
+
/** Défaut : `openid profile email ascencia.roles`. */
|
|
48
|
+
scopes?: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* Slug de l'application, pour `/api/v1/me/context?app=…`.
|
|
51
|
+
* Sans lui, le contexte est résolu au niveau de la zone seulement.
|
|
52
|
+
*/
|
|
53
|
+
app?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Réservé aux clients confidentiels (Node, Bun, PHP). Ne **jamais** le
|
|
56
|
+
* placer dans un bundle navigateur : il y serait lisible par tout le monde.
|
|
57
|
+
*/
|
|
58
|
+
clientSecret?: string;
|
|
59
|
+
/** Où vivent les jetons. Défaut : `'memory'`. */
|
|
60
|
+
storage?: StoragePreference;
|
|
61
|
+
/**
|
|
62
|
+
* Où vit la transaction PKCE (state, verifier, nonce). Défaut : `'session'`,
|
|
63
|
+
* parce qu'elle doit survivre à un aller-retour de navigation complet.
|
|
64
|
+
*/
|
|
65
|
+
transactionStorage?: StoragePreference;
|
|
66
|
+
fetch?: FetchLike;
|
|
67
|
+
/**
|
|
68
|
+
* Marge avant l'expiration réelle du jeton d'accès, en secondes. Couvre la
|
|
69
|
+
* dérive d'horloge et le temps de vol de la requête. Défaut : 60.
|
|
70
|
+
*/
|
|
71
|
+
clockSkewSeconds?: number;
|
|
72
|
+
/** Durée de vie du cache de découverte, en ms. Défaut : 1 h. */
|
|
73
|
+
discoveryTtlMs?: number;
|
|
74
|
+
/** Durée de vie du cache du contexte RBAC, en ms. Défaut : 60 s (docs/05-API.md §3). */
|
|
75
|
+
contextTtlMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Métadonnées fournies à la main. Court-circuite l'appel réseau à
|
|
78
|
+
* `/.well-known/openid-configuration` : utile hors ligne et en test.
|
|
79
|
+
*/
|
|
80
|
+
metadata?: DiscoveryDocument;
|
|
81
|
+
/** Préfixe des clés de stockage. Défaut : `'ascencia'`. */
|
|
82
|
+
storagePrefix?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Document de découverte OIDC. Seuls les deux endpoints indispensables au flux
|
|
87
|
+
* `authorization_code` sont obligatoires ; le reste dépend du déploiement.
|
|
88
|
+
*/
|
|
89
|
+
export interface DiscoveryDocument {
|
|
90
|
+
issuer: string;
|
|
91
|
+
authorization_endpoint: string;
|
|
92
|
+
token_endpoint: string;
|
|
93
|
+
userinfo_endpoint?: string;
|
|
94
|
+
jwks_uri?: string;
|
|
95
|
+
end_session_endpoint?: string;
|
|
96
|
+
revocation_endpoint?: string;
|
|
97
|
+
introspection_endpoint?: string;
|
|
98
|
+
scopes_supported?: string[];
|
|
99
|
+
response_types_supported?: string[];
|
|
100
|
+
response_modes_supported?: string[];
|
|
101
|
+
grant_types_supported?: string[];
|
|
102
|
+
code_challenge_methods_supported?: string[];
|
|
103
|
+
id_token_signing_alg_values_supported?: string[];
|
|
104
|
+
token_endpoint_auth_methods_supported?: string[];
|
|
105
|
+
[claim: string]: unknown;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface Tokens {
|
|
109
|
+
accessToken: string;
|
|
110
|
+
tokenType: string;
|
|
111
|
+
/** Instant d'expiration en epoch ms, déjà résolu depuis `expires_in`. */
|
|
112
|
+
expiresAt: number;
|
|
113
|
+
refreshToken: string | null;
|
|
114
|
+
idToken: string | null;
|
|
115
|
+
scope: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Identité normalisée.
|
|
120
|
+
*
|
|
121
|
+
* Les noms sont ceux de `/api/v1/me/context` (snake_case), pas ceux des claims
|
|
122
|
+
* OIDC : c'est cette forme que les attributs `data-ascencia-field` du widget et
|
|
123
|
+
* les SDK exposent au développeur. `getUser()` traduit `name` → `display_name`,
|
|
124
|
+
* `picture` → `avatar_url`, etc.
|
|
125
|
+
*/
|
|
126
|
+
export interface User {
|
|
127
|
+
id: string;
|
|
128
|
+
email: string | null;
|
|
129
|
+
email_verified: boolean;
|
|
130
|
+
display_name: string | null;
|
|
131
|
+
username: string | null;
|
|
132
|
+
avatar_url: string | null;
|
|
133
|
+
locale: string | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ContextRole {
|
|
137
|
+
key: string;
|
|
138
|
+
name?: string;
|
|
139
|
+
color?: string | null;
|
|
140
|
+
priority: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface ContextMembership {
|
|
144
|
+
status: string;
|
|
145
|
+
nickname?: string | null;
|
|
146
|
+
profile?: Record<string, unknown>;
|
|
147
|
+
first_login_at?: string | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface ContextSession {
|
|
151
|
+
id: string;
|
|
152
|
+
/** Niveau d'assurance de l'authentification : 1 = mot de passe, 2 = MFA. */
|
|
153
|
+
aal?: number;
|
|
154
|
+
amr?: string[];
|
|
155
|
+
expires_at?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Réponse de `GET /api/v1/me/context` : tout ce qu'il faut pour afficher une
|
|
160
|
+
* page en une seule requête.
|
|
161
|
+
*/
|
|
162
|
+
export interface AuthContext {
|
|
163
|
+
account: User;
|
|
164
|
+
realm: { slug: string; name?: string } | null;
|
|
165
|
+
application: { slug: string; name?: string } | null;
|
|
166
|
+
membership: ContextMembership | null;
|
|
167
|
+
roles: ContextRole[];
|
|
168
|
+
platform_roles: string[];
|
|
169
|
+
permissions: string[];
|
|
170
|
+
denied: string[];
|
|
171
|
+
session: ContextSession | null;
|
|
172
|
+
elevated: boolean;
|
|
173
|
+
etag?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type Prompt = 'none' | 'login' | 'consent' | 'select_account' | 'create';
|
|
177
|
+
|
|
178
|
+
export interface SignInOptions {
|
|
179
|
+
/** `'register'` ouvre l'onglet inscription (traduit en `prompt=create`). */
|
|
180
|
+
screen?: 'login' | 'register';
|
|
181
|
+
/** Saute l'écran de choix et part directement chez le provider (extension Ascencia). */
|
|
182
|
+
provider?: string;
|
|
183
|
+
prompt?: Prompt;
|
|
184
|
+
/** Pré-remplit l'adresse e-mail. */
|
|
185
|
+
loginHint?: string;
|
|
186
|
+
/** Remplace les scopes de la configuration, le temps de cette autorisation. */
|
|
187
|
+
scopes?: readonly string[];
|
|
188
|
+
redirectUri?: string;
|
|
189
|
+
/** `'fr'`, `'en'`… */
|
|
190
|
+
uiLocales?: string;
|
|
191
|
+
/** `'aal2'` force la MFA pour cette autorisation. */
|
|
192
|
+
acrValues?: string;
|
|
193
|
+
/** Ré-authentification forcée si la session est plus vieille, en secondes. */
|
|
194
|
+
maxAge?: number;
|
|
195
|
+
/** `'web_message'` est le mode du widget popup (docs/04-AUTH-FLOWS.md §7). */
|
|
196
|
+
responseMode?: 'query' | 'fragment' | 'form_post' | 'web_message';
|
|
197
|
+
/** Paramètres bruts additionnels, ajoutés tels quels à l'URL. */
|
|
198
|
+
extraParams?: Record<string, string>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Tout ce dont un appelant a besoin pour poursuivre le flux, popup ou redirection. */
|
|
202
|
+
export interface AuthorizeRequest {
|
|
203
|
+
url: string;
|
|
204
|
+
state: string;
|
|
205
|
+
nonce: string;
|
|
206
|
+
codeVerifier: string;
|
|
207
|
+
codeChallenge: string;
|
|
208
|
+
redirectUri: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface CallbackParams {
|
|
212
|
+
code?: string | null;
|
|
213
|
+
state?: string | null;
|
|
214
|
+
error?: string | null;
|
|
215
|
+
error_description?: string | null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Ce qu'accepte `handleCallback()` : l'URL de retour, ses paramètres, ou le message de la popup. */
|
|
219
|
+
export type CallbackInput = string | URL | URLSearchParams | CallbackParams;
|
|
220
|
+
|
|
221
|
+
export interface CallbackResult {
|
|
222
|
+
tokens: Tokens;
|
|
223
|
+
user: User;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface SignOutOptions {
|
|
227
|
+
/** Déconnexion SSO complète chez l'IdP, et pas seulement locale. */
|
|
228
|
+
global?: boolean;
|
|
229
|
+
postLogoutRedirectUri?: string;
|
|
230
|
+
/**
|
|
231
|
+
* Naviguer soi-même vers l'URL de déconnexion quand `global` est demandé.
|
|
232
|
+
* Défaut : `true` s'il y a un `location`. Mettre `false` pour récupérer
|
|
233
|
+
* l'URL et gérer la navigation à sa façon (framework router, popup…).
|
|
234
|
+
*/
|
|
235
|
+
redirect?: boolean;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Les arguments passés à chaque événement, indexés par nom. */
|
|
239
|
+
export interface AscenciaEventMap {
|
|
240
|
+
signin: [User];
|
|
241
|
+
signout: [];
|
|
242
|
+
refresh: [Tokens];
|
|
243
|
+
error: [AscenciaError];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type AscenciaEventName = keyof AscenciaEventMap;
|
|
247
|
+
|
|
248
|
+
export type AscenciaEventHandler<E extends AscenciaEventName> = (
|
|
249
|
+
...args: AscenciaEventMap[E]
|
|
250
|
+
) => void;
|
|
251
|
+
|
|
252
|
+
/** Fonction de désabonnement renvoyée par `on()`. */
|
|
253
|
+
export type Unsubscribe = () => void;
|