@opensas/oidc-sveltekit-adapter 0.0.0-stage → 0.0.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/README.md +215 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/oidc/domain-user.d.ts +7 -0
- package/dist/oidc/domain-user.js +35 -0
- package/dist/oidc/errors.d.ts +14 -0
- package/dist/oidc/errors.js +27 -0
- package/dist/oidc/permissions.d.ts +3 -0
- package/dist/oidc/permissions.js +32 -0
- package/dist/oidc/provider.d.ts +33 -0
- package/dist/oidc/provider.js +134 -0
- package/dist/oidc/request.d.ts +4 -0
- package/dist/oidc/request.js +23 -0
- package/dist/oidc/types.d.ts +1 -0
- package/dist/oidc/types.js +1 -0
- package/dist/oidc/user.d.ts +10 -0
- package/dist/oidc/user.js +27 -0
- package/dist/sveltekit-handlers.d.ts +18 -0
- package/dist/sveltekit-handlers.js +122 -0
- package/package.json +106 -3
package/README.md
CHANGED
|
@@ -1,3 +1,216 @@
|
|
|
1
|
-
#
|
|
1
|
+
# oidc-sveltekit-adapter
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Proyecto _de uso interno_ que resuelve el login por **OpenID Connect** (ADFS, Keycloak) para las
|
|
4
|
+
aplicaciones SvelteKit.
|
|
5
|
+
|
|
6
|
+
npm: `@opensas/oidc-sveltekit-adapter` (todavía sin publicar).
|
|
7
|
+
|
|
8
|
+
## Estado
|
|
9
|
+
|
|
10
|
+
Etapas 1 y 2 terminadas: la API genérica (`createOidcProvider` y helpers, en `src/lib/oidc/`, sin
|
|
11
|
+
SvelteKit) y `createOidcSvelteKitHandlers`, con la app de prueba (`src/routes/`) usando las dos, con
|
|
12
|
+
unitarios y e2e contra el Keycloak de Docker. Quedan para después el diagnóstico del proveedor y la
|
|
13
|
+
prueba con ADFS. El diseño
|
|
14
|
+
completo (API, cookies, flujo) y el plan de trabajo están en
|
|
15
|
+
[`docs/todos/oidc-sveltekit-adapter.md`](docs/todos/oidc-sveltekit-adapter.md).
|
|
16
|
+
|
|
17
|
+
## Uso
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add @opensas/oidc-sveltekit-adapter
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Authorization Code Flow con PKCE, sobre `openid-client`. Dos formas de usarlo.
|
|
24
|
+
|
|
25
|
+
### SvelteKit: `createOidcSvelteKitHandlers`
|
|
26
|
+
|
|
27
|
+
El componente arma las rutas `<basePath>/login`, `/callback` y `/logout`, las cookies del login y
|
|
28
|
+
los redirects. La app arma su sesión en `onLogin` y la borra en `onLogout`.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// src/lib/server/oidc.ts
|
|
32
|
+
import { createOidcSvelteKitHandlers, getUser } from '@opensas/oidc-sveltekit-adapter';
|
|
33
|
+
|
|
34
|
+
import { AUTH_CLIENT_ID, AUTH_CLIENT_SECRET, AUTH_ISSUER } from '$env/static/private';
|
|
35
|
+
|
|
36
|
+
export const oidc = createOidcSvelteKitHandlers({
|
|
37
|
+
issuer: AUTH_ISSUER, // el realm de Keycloak o https://<adfs>/adfs
|
|
38
|
+
clientId: AUTH_CLIENT_ID,
|
|
39
|
+
clientSecret: AUTH_CLIENT_SECRET, // sin él, client público (solo PKCE)
|
|
40
|
+
basePath: '/api/auth',
|
|
41
|
+
cookiePrefix: 'miapp',
|
|
42
|
+
sessionMaxAge: 8 * 3600,
|
|
43
|
+
onLogin: (tokens, { cookies }) => {
|
|
44
|
+
const user = getUser(tokens, { defaultDomain: 'ORGANIZACION' });
|
|
45
|
+
if (!user) return false; // sin usuario de red: 401
|
|
46
|
+
createSession(cookies, user); // la sesión es de la app
|
|
47
|
+
},
|
|
48
|
+
onLogout: ({ cookies }) => deleteSession(cookies),
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// src/routes/api/auth/[...path]/+server.ts
|
|
54
|
+
import { oidc } from '$lib/server/oidc';
|
|
55
|
+
|
|
56
|
+
export const GET = oidc.handler;
|
|
57
|
+
export const POST = oidc.handler;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// src/hooks.server.ts: sin sesión, las navegaciones van al login (con returnTo)
|
|
62
|
+
export const handle: Handle = ({ event, resolve }) => {
|
|
63
|
+
event.locals.user = getSession(event.cookies);
|
|
64
|
+
if (!event.locals.user) {
|
|
65
|
+
const login = oidc.loginRedirect(event);
|
|
66
|
+
if (login) redirect(303, login);
|
|
67
|
+
}
|
|
68
|
+
return resolve(event);
|
|
69
|
+
};
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
El logout es un `POST` a `<basePath>/logout` (por ejemplo, un `<form method="POST">`).
|
|
73
|
+
|
|
74
|
+
En el proveedor, el redirect URI del client es `<origin><basePath>/callback`
|
|
75
|
+
(`http://localhost:5173/api/auth/callback`), y el post logout redirect URI, `<origin>/`.
|
|
76
|
+
|
|
77
|
+
### Cualquier app Node: `createOidcProvider`
|
|
78
|
+
|
|
79
|
+
Los tres pasos del flujo, sin cookies ni redirects: los arma la app. Ejemplo completo en la app de
|
|
80
|
+
prueba (`src/routes/api/auth/`).
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const oidc = createOidcProvider({
|
|
84
|
+
issuer,
|
|
85
|
+
clientId,
|
|
86
|
+
clientSecret,
|
|
87
|
+
callbackPath: '/api/auth/callback',
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// GET /login: guardar pendingLogin (cookie HttpOnly) y redirigir a providerUrl
|
|
91
|
+
const { providerUrl, pendingLogin } = await oidc.buildLoginUrl(requestUrl);
|
|
92
|
+
|
|
93
|
+
// GET /callback: valida state, PKCE y el id_token; lanza OidcError si falla
|
|
94
|
+
const tokens = await oidc.exchangeCode(requestUrl, pendingLogin);
|
|
95
|
+
const user = getUser(tokens);
|
|
96
|
+
|
|
97
|
+
// POST /logout: redirigir al logout del proveedor (undefined si no lo publica)
|
|
98
|
+
const logoutUrl = await oidc.buildLogoutUrl(idToken, 'https://miapp/');
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Helpers: `getUser`, `getDomainUser`, `getPermissions`, `isSameOrigin`, `isNavigation`, `OidcError`
|
|
102
|
+
(con `code`: `PROVIDER_ERROR`, `NO_LOGIN_IN_PROGRESS`, `STATE_MISMATCH`, `TOKEN_EXCHANGE`,
|
|
103
|
+
`INVALID_ID_TOKEN`, `DISCOVERY`, `LOGIN_REJECTED`).
|
|
104
|
+
|
|
105
|
+
El paso a paso del flujo, con los mensajes HTTP, está en la app de prueba: `pnpm dev` y
|
|
106
|
+
`/como-funciona`.
|
|
107
|
+
|
|
108
|
+
## Proveedores probados
|
|
109
|
+
|
|
110
|
+
El flujo se probó en la prueba de concepto (`keycloak-poc`), con la misma app y cambiando solo el
|
|
111
|
+
`.env`. El componente se va a probar contra los mismos proveedores:
|
|
112
|
+
|
|
113
|
+
| Proveedor | Versión | Notas |
|
|
114
|
+
| --------- | ------------------------------------------------------------- | -------------------------------------------------- |
|
|
115
|
+
| Keycloak | 26.8.1 | En Docker (`start-dev`) y en Windows Server 2022 |
|
|
116
|
+
| ADFS | AD FS 2019 (farm behavior level 4), sobre Windows Server 2022 | Client confidencial y público (PKCE), con Kerberos |
|
|
117
|
+
|
|
118
|
+
## Estructura
|
|
119
|
+
|
|
120
|
+
Dos capas:
|
|
121
|
+
|
|
122
|
+
- `src/lib/oidc/`: la capa genérica, para cualquier app Node. Sobre `openid-client`; no importa
|
|
123
|
+
nada de SvelteKit (lo controla ESLint).
|
|
124
|
+
- La capa SvelteKit: los handlers de `/api/auth/login`, `/callback` y `/logout`, y
|
|
125
|
+
`createOidcSvelteKitHandlers` (etapa 2).
|
|
126
|
+
|
|
127
|
+
`src/routes/` es la app de prueba, contra Keycloak en Docker.
|
|
128
|
+
|
|
129
|
+
## Desarrollo
|
|
130
|
+
|
|
131
|
+
Requisitos: las versiones de `mise.toml` (Node, pnpm), Docker y `openssl` (para Keycloak). pnpm lo
|
|
132
|
+
instala corepack según `packageManager` de `package.json`.
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pnpm install
|
|
136
|
+
cp .env.example .env # trae activo el bloque del Keycloak de Docker
|
|
137
|
+
pnpm keycloak:start
|
|
138
|
+
pnpm dev # la app de prueba, en http://localhost:5173
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Tareas de pnpm
|
|
142
|
+
|
|
143
|
+
Todas las de `package.json`. **Al agregar, cambiar o borrar una, actualizar esta lista.**
|
|
144
|
+
|
|
145
|
+
App de prueba:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pnpm dev # vite dev; confía en el certificado de Keycloak (NODE_EXTRA_CA_CERTS)
|
|
149
|
+
pnpm build # vite build y después prepack
|
|
150
|
+
pnpm preview # sirve el build en el 4173, el que usan los e2e; mismo certificado que dev
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Calidad:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pnpm verify # ESLint con --fix, Prettier y svelte-check: correrlo antes de commitear
|
|
157
|
+
pnpm lint # solo ESLint, sin --fix
|
|
158
|
+
pnpm format # solo Prettier
|
|
159
|
+
pnpm check # solo svelte-check (check:watch, en modo watch)
|
|
160
|
+
pnpm lint:clean # borra las cachés de ESLint y Prettier
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Tests. `pnpm test` usa el Keycloak de dev ya levantado (`pnpm keycloak:start`), como una base de
|
|
164
|
+
fixture; `pnpm test:ci` levanta uno propio y lo borra al terminar:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pnpm test # test:vite y test:play
|
|
168
|
+
pnpm test:unit # Vitest, solo los unitarios
|
|
169
|
+
pnpm test:vite # Vitest, todos los proyectos
|
|
170
|
+
pnpm test:e2e # Playwright, solo los e2e
|
|
171
|
+
pnpm test:play # Playwright, todos los proyectos
|
|
172
|
+
pnpm test:ci # como en GitLab: Keycloak desechable (puerto 3112), test:vite y test:play
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Keycloak en Docker (`docker/keycloak/`, `https://localhost:3102`, realm `oidc-test`, admin
|
|
176
|
+
`admin`/`admin`):
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
pnpm keycloak:start # keycloak:cert y levanta el contenedor; importa el realm si no existe
|
|
180
|
+
pnpm keycloak:stop # lo frena, sin perder los datos
|
|
181
|
+
pnpm keycloak:status # estado y puertos del contenedor
|
|
182
|
+
pnpm keycloak:logs # los logs, en vivo
|
|
183
|
+
pnpm keycloak:purge # borra el contenedor y sus datos: el próximo start reimporta el realm
|
|
184
|
+
pnpm keycloak:cert # genera el certificado autofirmado, si no existe o vence en 30 días
|
|
185
|
+
pnpm keycloak:init # espera e importa el realm por la API de admin (el de CI; KEYCLOAK_URL)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Paquete y dependencias:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
pnpm prepack # arma dist/ y lo valida con publint
|
|
192
|
+
pnpm up:safe # dentro de los rangos, con 24 h de antigüedad mínima
|
|
193
|
+
pnpm sv:sync-deps # compara las mayores con las que trae hoy el scaffold de SvelteKit (sv)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
VM de ADFS (`keycloak-poc`: `docs/adfs-paso-a-paso.md`):
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
pnpm vm:pack # build de adapter-node con node_modules → build-vm/oidc-app.zip (.env.vm.example)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Runner de GitLab local (con `sudo`):
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
pnpm gitlab-runner:start
|
|
206
|
+
pnpm gitlab-runner:stop
|
|
207
|
+
pnpm gitlab-runner:status
|
|
208
|
+
pnpm gitlab-runner:logs # los logs del servicio, en vivo
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`prepare` (`svelte-kit sync`) no se corre a mano: lo corre `pnpm install`.
|
|
212
|
+
|
|
213
|
+
## Versiones y commits
|
|
214
|
+
|
|
215
|
+
SemVer. Commits y títulos de merge request en Conventional Commits, en español. `main` protegida:
|
|
216
|
+
todo entra por merge request, con squash.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './oidc/domain-user.ts';
|
|
2
|
+
export * from './oidc/errors.ts';
|
|
3
|
+
export { isNavigation, isSameOrigin } from './oidc/request.ts';
|
|
4
|
+
export * from './oidc/permissions.ts';
|
|
5
|
+
export * from './oidc/provider.ts';
|
|
6
|
+
export type { Claims } from './oidc/types.ts';
|
|
7
|
+
export * from './oidc/user.ts';
|
|
8
|
+
export * from './sveltekit-handlers.ts';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// La API pública. Etapa 1, cualquier app Node: el provider (buildLoginUrl, exchangeCode,
|
|
2
|
+
// buildLogoutUrl) y helpers; las cookies, la sesión y los redirects los arma la app. Etapa 2,
|
|
3
|
+
// SvelteKit: createOidcSvelteKitHandlers hace eso y la app solo arma su sesión.
|
|
4
|
+
// src/lib/server/ es de la app de prueba: no se publica.
|
|
5
|
+
export * from "./oidc/domain-user.js";
|
|
6
|
+
export * from "./oidc/errors.js";
|
|
7
|
+
export { isNavigation, isSameOrigin } from "./oidc/request.js";
|
|
8
|
+
export * from "./oidc/permissions.js";
|
|
9
|
+
export * from "./oidc/provider.js";
|
|
10
|
+
export * from "./oidc/user.js";
|
|
11
|
+
// Etapa 2: la capa SvelteKit
|
|
12
|
+
export * from "./sveltekit-handlers.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Claims } from './types.ts';
|
|
2
|
+
export declare const DOMAIN_USER_CLAIMS: readonly ["unique_name", "upn", "preferred_username", "email"];
|
|
3
|
+
export declare const DOMAIN_CLAIM = "dominio";
|
|
4
|
+
export type DomainUserOptions = {
|
|
5
|
+
defaultDomain?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function getDomainUser(idTokenClaims: Claims, accessTokenClaims: Claims | undefined, options?: DomainUserOptions): string | undefined;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Dónde manda cada proveedor el usuario de red, en orden: gana el primero con contenido. ADFS (sin
|
|
2
|
+
// reglas de claims): unique_name ('ORGANIZACION\user1') y upn; Keycloak: preferred_username (con
|
|
3
|
+
// LDAP, el userPrincipalName del AD). Verificado en la POC (keycloak-poc).
|
|
4
|
+
export const DOMAIN_USER_CLAIMS = ['unique_name', 'upn', 'preferred_username', 'email'];
|
|
5
|
+
// Mapper del client en Keycloak: el dominio de Windows del usuario. Solo en el access token.
|
|
6
|
+
export const DOMAIN_CLAIM = 'dominio';
|
|
7
|
+
const DOMAIN_SEPARATOR = '\\';
|
|
8
|
+
const UPN_SEPARATOR = '@';
|
|
9
|
+
// El usuario de red, 'DOMINIO\usuario', o undefined si ningún claim lo trae. Por cada claim de
|
|
10
|
+
// DOMAIN_USER_CLAIMS mira primero el id_token (validado) y después el access token. Si el valor ya
|
|
11
|
+
// trae el dominio queda tal cual; si es 'usuario@dominio.algo' (o solo 'usuario'), arma
|
|
12
|
+
// '<dominio>\usuario' con el claim dominio o, si no está, con defaultDomain (sin él, undefined).
|
|
13
|
+
export function getDomainUser(idTokenClaims, accessTokenClaims, options = {}) {
|
|
14
|
+
const value = DOMAIN_USER_CLAIMS.flatMap((claim) => [
|
|
15
|
+
stringClaim(idTokenClaims, claim),
|
|
16
|
+
stringClaim(accessTokenClaims, claim),
|
|
17
|
+
]).find(Boolean);
|
|
18
|
+
if (!value)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (value.includes(DOMAIN_SEPARATOR))
|
|
21
|
+
return value;
|
|
22
|
+
const [user] = value.split(UPN_SEPARATOR);
|
|
23
|
+
if (!user)
|
|
24
|
+
return undefined;
|
|
25
|
+
const domain = stringClaim(accessTokenClaims, DOMAIN_CLAIM) ??
|
|
26
|
+
stringClaim(idTokenClaims, DOMAIN_CLAIM) ??
|
|
27
|
+
options.defaultDomain;
|
|
28
|
+
if (!domain)
|
|
29
|
+
return undefined;
|
|
30
|
+
return `${domain}${DOMAIN_SEPARATOR}${user}`;
|
|
31
|
+
}
|
|
32
|
+
function stringClaim(claims, name) {
|
|
33
|
+
const value = claims?.[name];
|
|
34
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
35
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const OIDC_ERROR_CODES: readonly ["PROVIDER_ERROR", "NO_LOGIN_IN_PROGRESS", "STATE_MISMATCH", "TOKEN_EXCHANGE", "INVALID_ID_TOKEN", "DISCOVERY", "LOGIN_REJECTED"];
|
|
2
|
+
export type OidcErrorCode = (typeof OIDC_ERROR_CODES)[number];
|
|
3
|
+
export type ProviderErrorDetails = {
|
|
4
|
+
providerError?: string;
|
|
5
|
+
providerErrorDescription?: string;
|
|
6
|
+
};
|
|
7
|
+
export declare class OidcError extends Error {
|
|
8
|
+
readonly code: OidcErrorCode;
|
|
9
|
+
readonly providerError?: string;
|
|
10
|
+
readonly providerErrorDescription?: string;
|
|
11
|
+
constructor(code: OidcErrorCode, message: string, details?: ProviderErrorDetails & {
|
|
12
|
+
cause?: unknown;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const OIDC_ERROR_CODES = [
|
|
2
|
+
// El proveedor volvió con ?error= (el usuario canceló, client mal configurado)
|
|
3
|
+
'PROVIDER_ERROR',
|
|
4
|
+
// Sin <prefix>_login: vencida, o el callback abierto a mano
|
|
5
|
+
'NO_LOGIN_IN_PROGRESS',
|
|
6
|
+
'STATE_MISMATCH',
|
|
7
|
+
// Falló el canje del code
|
|
8
|
+
'TOKEN_EXCHANGE',
|
|
9
|
+
// Firma, iss, aud, exp o nonce del id_token
|
|
10
|
+
'INVALID_ID_TOKEN',
|
|
11
|
+
// El proveedor no responde
|
|
12
|
+
'DISCOVERY',
|
|
13
|
+
// El login salió bien, pero la app no lo aceptó (por ejemplo, sin usuario de red)
|
|
14
|
+
'LOGIN_REJECTED',
|
|
15
|
+
];
|
|
16
|
+
export class OidcError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
providerError;
|
|
19
|
+
providerErrorDescription;
|
|
20
|
+
constructor(code, message, details = {}) {
|
|
21
|
+
super(message, { cause: details.cause });
|
|
22
|
+
this.name = 'OidcError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.providerError = details.providerError;
|
|
25
|
+
this.providerErrorDescription = details.providerErrorDescription;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const ROLE_SEPARATOR = ':';
|
|
2
|
+
// Los roles del client en Keycloak ('Recurso:accion', en resource_access.<clientId>.roles, la unión
|
|
3
|
+
// de los de todos los grupos del usuario) como permisos. Solo vienen en el access token. Los roles
|
|
4
|
+
// que no tienen la forma 'Recurso:accion' se ignoran. Sin roles (ADFS sin reglas), {}.
|
|
5
|
+
//
|
|
6
|
+
// ['Provincia:alta', 'Provincia:consulta', 'Dato:consulta']
|
|
7
|
+
// → { Dato: ['consulta'], Provincia: ['alta', 'consulta'] }
|
|
8
|
+
export function getPermissions(accessTokenClaims, clientId) {
|
|
9
|
+
const permissions = {};
|
|
10
|
+
for (const role of clientRoles(accessTokenClaims, clientId)) {
|
|
11
|
+
const [resource, action, ...rest] = role.split(ROLE_SEPARATOR);
|
|
12
|
+
if (!resource || !action || rest.length > 0)
|
|
13
|
+
continue;
|
|
14
|
+
(permissions[resource] ??= new Set()).add(action);
|
|
15
|
+
}
|
|
16
|
+
// El orden de los roles en el token no es estable: recursos y acciones ordenados
|
|
17
|
+
return Object.fromEntries(Object.keys(permissions)
|
|
18
|
+
.toSorted()
|
|
19
|
+
.map((resource) => [resource, [...permissions[resource]].toSorted()]));
|
|
20
|
+
}
|
|
21
|
+
function clientRoles(claims, clientId) {
|
|
22
|
+
const resourceAccess = claims?.resource_access;
|
|
23
|
+
if (!isRecord(resourceAccess))
|
|
24
|
+
return [];
|
|
25
|
+
const client = resourceAccess[clientId];
|
|
26
|
+
if (!isRecord(client) || !Array.isArray(client.roles))
|
|
27
|
+
return [];
|
|
28
|
+
return client.roles.filter((role) => typeof role === 'string');
|
|
29
|
+
}
|
|
30
|
+
function isRecord(value) {
|
|
31
|
+
return typeof value === 'object' && value !== null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Claims } from './types.ts';
|
|
2
|
+
export declare const DEFAULT_SCOPE = "openid profile email";
|
|
3
|
+
export declare const RETURN_TO_PARAM = "returnTo";
|
|
4
|
+
export type OidcProviderOptions = {
|
|
5
|
+
issuer: string;
|
|
6
|
+
clientId: string;
|
|
7
|
+
clientSecret?: string;
|
|
8
|
+
callbackPath: string;
|
|
9
|
+
scope?: string;
|
|
10
|
+
allowInsecureRequests?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type PendingLogin = {
|
|
13
|
+
codeVerifier: string;
|
|
14
|
+
state: string;
|
|
15
|
+
nonce: string;
|
|
16
|
+
returnTo: string;
|
|
17
|
+
};
|
|
18
|
+
export type Tokens = {
|
|
19
|
+
idToken: string;
|
|
20
|
+
accessToken: string;
|
|
21
|
+
refreshToken?: string;
|
|
22
|
+
idTokenClaims: Claims;
|
|
23
|
+
accessTokenClaims?: Claims;
|
|
24
|
+
};
|
|
25
|
+
export type OidcProvider = {
|
|
26
|
+
buildLoginUrl(loginUrl: URL): Promise<{
|
|
27
|
+
providerUrl: URL;
|
|
28
|
+
pendingLogin: PendingLogin;
|
|
29
|
+
}>;
|
|
30
|
+
exchangeCode(callbackUrl: URL, pendingLogin: PendingLogin | undefined): Promise<Tokens>;
|
|
31
|
+
buildLogoutUrl(idToken: string | undefined, postLogoutRedirectUri: string): Promise<URL | undefined>;
|
|
32
|
+
};
|
|
33
|
+
export declare function createOidcProvider(options: OidcProviderOptions): OidcProvider;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as client from 'openid-client';
|
|
2
|
+
import { OidcError } from "./errors.js";
|
|
3
|
+
import { safeReturnTo } from "./request.js";
|
|
4
|
+
// Keycloak rechaza los scopes que no conoce (invalid_scope): allatclaims, el de ADFS, no va por defecto
|
|
5
|
+
export const DEFAULT_SCOPE = 'openid profile email';
|
|
6
|
+
// /login?returnTo=/pagina: a dónde volver después del login
|
|
7
|
+
export const RETURN_TO_PARAM = 'returnTo';
|
|
8
|
+
const JWT_PARTS = 3;
|
|
9
|
+
// Errores de openid-client (oauth4webapi) que dicen que el id_token no es válido: firma (llega
|
|
10
|
+
// como respuesta inválida), iss, aud, exp, nonce, o la clave del JWKS.
|
|
11
|
+
const INVALID_ID_TOKEN_ERROR_CODES = new Set([
|
|
12
|
+
'OAUTH_INVALID_RESPONSE',
|
|
13
|
+
'OAUTH_JWT_CLAIM_COMPARISON_FAILED',
|
|
14
|
+
'OAUTH_JWT_TIMESTAMP_CHECK_FAILED',
|
|
15
|
+
'OAUTH_KEY_SELECTION_FAILED',
|
|
16
|
+
]);
|
|
17
|
+
// Discovery la primera vez que se usa; si falla, no se cachea el error y el próximo pedido reintenta.
|
|
18
|
+
// Así la app levanta aunque el proveedor esté caído.
|
|
19
|
+
export function createOidcProvider(options) {
|
|
20
|
+
const scope = options.scope ?? DEFAULT_SCOPE;
|
|
21
|
+
let configuration;
|
|
22
|
+
// Una sola promesa compartida: los pedidos que llegan mientras se resuelve esperan la misma
|
|
23
|
+
function discover() {
|
|
24
|
+
configuration ??= runDiscovery();
|
|
25
|
+
return configuration;
|
|
26
|
+
}
|
|
27
|
+
async function runDiscovery() {
|
|
28
|
+
try {
|
|
29
|
+
return await client.discovery(new URL(options.issuer), options.clientId, options.clientSecret, options.clientSecret ? undefined : client.None(), options.allowInsecureRequests ? { execute: [client.allowInsecureRequests] } : undefined);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
configuration = undefined;
|
|
33
|
+
throw new OidcError('DISCOVERY', `no se pudo leer el discovery de ${options.issuer}`, {
|
|
34
|
+
cause: err,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
async buildLoginUrl(loginUrl) {
|
|
40
|
+
const config = await discover();
|
|
41
|
+
const codeVerifier = client.randomPKCECodeVerifier();
|
|
42
|
+
const state = client.randomState();
|
|
43
|
+
const nonce = client.randomNonce();
|
|
44
|
+
const providerUrl = client.buildAuthorizationUrl(config, {
|
|
45
|
+
redirect_uri: new URL(options.callbackPath, loginUrl.origin).href,
|
|
46
|
+
scope,
|
|
47
|
+
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
|
48
|
+
code_challenge_method: 'S256',
|
|
49
|
+
state,
|
|
50
|
+
nonce,
|
|
51
|
+
});
|
|
52
|
+
const returnTo = safeReturnTo(loginUrl.searchParams.get(RETURN_TO_PARAM));
|
|
53
|
+
return { providerUrl, pendingLogin: { codeVerifier, state, nonce, returnTo } };
|
|
54
|
+
},
|
|
55
|
+
async exchangeCode(callbackUrl, pendingLogin) {
|
|
56
|
+
// Antes de openid-client: sus errores no distinguen estos casos de un id_token inválido
|
|
57
|
+
const providerError = callbackUrl.searchParams.get('error');
|
|
58
|
+
if (providerError)
|
|
59
|
+
throw new OidcError('PROVIDER_ERROR', 'el proveedor devolvió un error en el callback', {
|
|
60
|
+
providerError,
|
|
61
|
+
providerErrorDescription: callbackUrl.searchParams.get('error_description') ?? undefined,
|
|
62
|
+
});
|
|
63
|
+
if (!pendingLogin)
|
|
64
|
+
throw new OidcError('NO_LOGIN_IN_PROGRESS', 'no hay un login en curso');
|
|
65
|
+
if (callbackUrl.searchParams.get('state') !== pendingLogin.state)
|
|
66
|
+
throw new OidcError('STATE_MISMATCH', 'el state del callback no coincide');
|
|
67
|
+
const config = await discover();
|
|
68
|
+
let response;
|
|
69
|
+
try {
|
|
70
|
+
response = await client.authorizationCodeGrant(config, callbackUrl, {
|
|
71
|
+
pkceCodeVerifier: pendingLogin.codeVerifier,
|
|
72
|
+
expectedState: pendingLogin.state,
|
|
73
|
+
expectedNonce: pendingLogin.nonce,
|
|
74
|
+
idTokenExpected: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
throw toOidcError(err);
|
|
79
|
+
}
|
|
80
|
+
const idTokenClaims = response.claims();
|
|
81
|
+
if (!response.id_token || !idTokenClaims)
|
|
82
|
+
throw new OidcError('INVALID_ID_TOKEN', 'el proveedor no devolvió un id_token');
|
|
83
|
+
return {
|
|
84
|
+
idToken: response.id_token,
|
|
85
|
+
accessToken: response.access_token,
|
|
86
|
+
refreshToken: response.refresh_token,
|
|
87
|
+
idTokenClaims: { ...idTokenClaims },
|
|
88
|
+
accessTokenClaims: decodeJwtPayload(response.access_token),
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
async buildLogoutUrl(idToken, postLogoutRedirectUri) {
|
|
92
|
+
const config = await discover();
|
|
93
|
+
return config.serverMetadata().end_session_endpoint
|
|
94
|
+
? client.buildEndSessionUrl(config, {
|
|
95
|
+
...(idToken && { id_token_hint: idToken }),
|
|
96
|
+
post_logout_redirect_uri: postLogoutRedirectUri,
|
|
97
|
+
})
|
|
98
|
+
: undefined;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function toOidcError(err) {
|
|
103
|
+
if (err instanceof client.AuthorizationResponseError)
|
|
104
|
+
return new OidcError('PROVIDER_ERROR', 'el proveedor devolvió un error en el callback', {
|
|
105
|
+
providerError: err.error,
|
|
106
|
+
providerErrorDescription: err.error_description,
|
|
107
|
+
cause: err,
|
|
108
|
+
});
|
|
109
|
+
if (err instanceof client.ResponseBodyError)
|
|
110
|
+
return new OidcError('TOKEN_EXCHANGE', 'el proveedor rechazó el canje del code', {
|
|
111
|
+
providerError: err.error,
|
|
112
|
+
providerErrorDescription: err.error_description,
|
|
113
|
+
cause: err,
|
|
114
|
+
});
|
|
115
|
+
const code = err instanceof Error && 'code' in err ? err.code : undefined;
|
|
116
|
+
if (typeof code === 'string' && INVALID_ID_TOKEN_ERROR_CODES.has(code))
|
|
117
|
+
return new OidcError('INVALID_ID_TOKEN', 'el id_token no es válido', { cause: err });
|
|
118
|
+
return new OidcError('TOKEN_EXCHANGE', 'falló el canje del code', { cause: err });
|
|
119
|
+
}
|
|
120
|
+
// Payload de un JWT sin verificar la firma, o undefined si no es un JWT
|
|
121
|
+
function decodeJwtPayload(jwt) {
|
|
122
|
+
const parts = jwt.split('.');
|
|
123
|
+
if (parts.length !== JWT_PARTS)
|
|
124
|
+
return undefined;
|
|
125
|
+
try {
|
|
126
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
127
|
+
return typeof payload === 'object' && payload !== null && !Array.isArray(payload)
|
|
128
|
+
? payload
|
|
129
|
+
: undefined;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const DEFAULT_RETURN_TO = "/";
|
|
2
|
+
export declare function isSameOrigin(request: Request, origin: string): boolean;
|
|
3
|
+
export declare function isNavigation(request: Request): boolean;
|
|
4
|
+
export declare function safeReturnTo(value: string | null | undefined): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const DEFAULT_RETURN_TO = '/';
|
|
2
|
+
const HTML_MEDIA_TYPE = 'text/html';
|
|
3
|
+
// La cabecera Origin del pedido es la de la app. Sin Origin, no: el logout lo hace un form del
|
|
4
|
+
// propio sitio, y los navegadores mandan Origin en todo POST.
|
|
5
|
+
export function isSameOrigin(request, origin) {
|
|
6
|
+
return request.headers.get('origin') === origin;
|
|
7
|
+
}
|
|
8
|
+
// Una navegación del navegador (GET que acepta text/html), no un fetch de la app. Las navegaciones
|
|
9
|
+
// del lado del cliente de un framework (SvelteKit: event.isDataRequest) llegan como fetch: las
|
|
10
|
+
// tiene que sumar la app.
|
|
11
|
+
export function isNavigation(request) {
|
|
12
|
+
return (request.method === 'GET' && Boolean(request.headers.get('accept')?.includes(HTML_MEDIA_TYPE)));
|
|
13
|
+
}
|
|
14
|
+
// Solo rutas propias: empieza con una sola '/'. '//otro-sitio' y '/\otro-sitio' los navegadores los
|
|
15
|
+
// toman como una URL de otro origen (open redirect), igual que 'https://otro-sitio'.
|
|
16
|
+
export function safeReturnTo(value) {
|
|
17
|
+
if (!value?.startsWith('/'))
|
|
18
|
+
return DEFAULT_RETURN_TO;
|
|
19
|
+
const second = value.charAt(1);
|
|
20
|
+
if (second === '/' || second === '\\')
|
|
21
|
+
return DEFAULT_RETURN_TO;
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type Claims = Record<string, unknown>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type DomainUserOptions } from './domain-user.ts';
|
|
2
|
+
import { type Permissions } from './permissions.ts';
|
|
3
|
+
import type { Tokens } from './provider.ts';
|
|
4
|
+
export type OidcUser = {
|
|
5
|
+
domainUser: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
groups: string[];
|
|
8
|
+
permissions: Permissions;
|
|
9
|
+
};
|
|
10
|
+
export declare function getUser(tokens: Pick<Tokens, 'idTokenClaims' | 'accessTokenClaims'>, options?: DomainUserOptions): OidcUser | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getDomainUser } from "./domain-user.js";
|
|
2
|
+
import { getPermissions } from "./permissions.js";
|
|
3
|
+
// El usuario a partir de los tokens de exchangeCode, o undefined si no hay usuario de red. El
|
|
4
|
+
// nombre, primero del id_token (validado). Grupos y roles, del access token: Keycloak los manda
|
|
5
|
+
// solo ahí. Los roles son los del client al que se le emitió el token (claim azp). Sin access
|
|
6
|
+
// token JWT, sin grupos ni permisos. Guardarlo (la sesión) es cosa de la app.
|
|
7
|
+
export function getUser(tokens, options) {
|
|
8
|
+
const { idTokenClaims, accessTokenClaims } = tokens;
|
|
9
|
+
const domainUser = getDomainUser(idTokenClaims, accessTokenClaims, options);
|
|
10
|
+
if (!domainUser)
|
|
11
|
+
return undefined;
|
|
12
|
+
const clientId = stringClaim(accessTokenClaims, 'azp');
|
|
13
|
+
return {
|
|
14
|
+
domainUser,
|
|
15
|
+
name: stringClaim(idTokenClaims, 'name') ?? stringClaim(accessTokenClaims, 'name'),
|
|
16
|
+
groups: stringArrayClaim(accessTokenClaims, 'groups'),
|
|
17
|
+
permissions: clientId ? getPermissions(accessTokenClaims, clientId) : {},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function stringClaim(claims, name) {
|
|
21
|
+
const value = claims?.[name];
|
|
22
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
function stringArrayClaim(claims, name) {
|
|
25
|
+
const value = claims?.[name];
|
|
26
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
27
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type RequestEvent, type RequestHandler } from '@sveltejs/kit';
|
|
2
|
+
import { OidcError } from './oidc/errors.ts';
|
|
3
|
+
import { type OidcProvider, type OidcProviderOptions, type Tokens } from './oidc/provider.ts';
|
|
4
|
+
export type OidcSvelteKitOptions = Omit<OidcProviderOptions, 'callbackPath'> & {
|
|
5
|
+
basePath: string;
|
|
6
|
+
cookiePrefix: string;
|
|
7
|
+
sessionMaxAge?: number;
|
|
8
|
+
postLogoutPath?: string;
|
|
9
|
+
onLogin: (tokens: Tokens, event: RequestEvent) => void | false | Promise<void | false>;
|
|
10
|
+
onLogout?: (event: RequestEvent) => void | Promise<void>;
|
|
11
|
+
onLoginError?: (error: OidcError, event: RequestEvent) => Response | Promise<Response>;
|
|
12
|
+
};
|
|
13
|
+
export type OidcSvelteKitHandlers = {
|
|
14
|
+
handler: RequestHandler;
|
|
15
|
+
loginRedirect: (event: RequestEvent) => string | undefined;
|
|
16
|
+
provider: OidcProvider;
|
|
17
|
+
};
|
|
18
|
+
export declare function createOidcSvelteKitHandlers(options: OidcSvelteKitOptions): OidcSvelteKitHandlers;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// La capa SvelteKit (etapa 2): hace con el provider lo que en la etapa 1 arma a mano la app (ver la
|
|
2
|
+
// app de prueba, src/routes/api/auth/): las rutas, las cookies, los redirects y la página del login
|
|
3
|
+
// fallido. La app solo arma su sesión (onLogin) y la borra (onLogout).
|
|
4
|
+
import { error, redirect } from '@sveltejs/kit';
|
|
5
|
+
import { OidcError } from "./oidc/errors.js";
|
|
6
|
+
import { createOidcProvider, RETURN_TO_PARAM, } from "./oidc/provider.js";
|
|
7
|
+
import { isNavigation, isSameOrigin } from "./oidc/request.js";
|
|
8
|
+
const LOGIN_PATH = '/login';
|
|
9
|
+
const CALLBACK_PATH = '/callback';
|
|
10
|
+
const LOGOUT_PATH = '/logout';
|
|
11
|
+
const DEFAULT_POST_LOGOUT_PATH = '/';
|
|
12
|
+
const SECONDS_PER_MINUTE = 60;
|
|
13
|
+
const LOGIN_MAX_AGE = 10 * SECONDS_PER_MINUTE;
|
|
14
|
+
const SEE_OTHER = 303;
|
|
15
|
+
const UNAUTHORIZED = 401;
|
|
16
|
+
const FORBIDDEN = 403;
|
|
17
|
+
const NOT_FOUND = 404;
|
|
18
|
+
const METHOD_NOT_ALLOWED = 405;
|
|
19
|
+
export function createOidcSvelteKitHandlers(options) {
|
|
20
|
+
const { basePath, cookiePrefix, sessionMaxAge, postLogoutPath = DEFAULT_POST_LOGOUT_PATH, onLogin, onLogout, onLoginError = (err) => defaultLoginError(err, basePath), ...providerOptions } = options;
|
|
21
|
+
const provider = createOidcProvider({
|
|
22
|
+
...providerOptions,
|
|
23
|
+
callbackPath: `${basePath}${CALLBACK_PATH}`,
|
|
24
|
+
});
|
|
25
|
+
// Kit solo redirige a otro origen si está en la lista: login y logout van al proveedor
|
|
26
|
+
const external = [new URL(options.issuer).origin];
|
|
27
|
+
const loginCookie = cookie(`${cookiePrefix}_login`, `${basePath}${CALLBACK_PATH}`, LOGIN_MAX_AGE);
|
|
28
|
+
const logoutCookie = cookie(`${cookiePrefix}_logout`, `${basePath}${LOGOUT_PATH}`, sessionMaxAge);
|
|
29
|
+
const login = async ({ url, cookies }) => {
|
|
30
|
+
const { providerUrl, pendingLogin } = await provider.buildLoginUrl(url);
|
|
31
|
+
cookies.set(loginCookie.name, JSON.stringify(pendingLogin), loginCookie.options);
|
|
32
|
+
redirect(SEE_OTHER, providerUrl.href, { external });
|
|
33
|
+
};
|
|
34
|
+
const callback = async (event) => {
|
|
35
|
+
const { url, cookies } = event;
|
|
36
|
+
const pendingLogin = parsePendingLogin(cookies.get(loginCookie.name));
|
|
37
|
+
cookies.delete(loginCookie.name, loginCookie.options);
|
|
38
|
+
try {
|
|
39
|
+
const tokens = await provider.exchangeCode(url, pendingLogin);
|
|
40
|
+
if ((await onLogin(tokens, event)) === false)
|
|
41
|
+
throw new OidcError('LOGIN_REJECTED', 'la app no aceptó el login');
|
|
42
|
+
cookies.set(logoutCookie.name, tokens.idToken, logoutCookie.options);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (!(err instanceof OidcError))
|
|
46
|
+
throw err;
|
|
47
|
+
return await onLoginError(err, event);
|
|
48
|
+
}
|
|
49
|
+
// pendingLogin está: si no, exchangeCode lanzó NO_LOGIN_IN_PROGRESS
|
|
50
|
+
redirect(SEE_OTHER, pendingLogin.returnTo);
|
|
51
|
+
};
|
|
52
|
+
const logout = async (event) => {
|
|
53
|
+
const { request, url, cookies } = event;
|
|
54
|
+
// Propio aunque SvelteKit tenga su chequeo de CSRF: no corre en dev y una app lo puede desactivar
|
|
55
|
+
if (!isSameOrigin(request, url.origin))
|
|
56
|
+
error(FORBIDDEN, 'Origin distinto del de la app');
|
|
57
|
+
const idToken = cookies.get(logoutCookie.name);
|
|
58
|
+
cookies.delete(logoutCookie.name, logoutCookie.options);
|
|
59
|
+
await onLogout?.(event);
|
|
60
|
+
const home = new URL(postLogoutPath, url.origin);
|
|
61
|
+
const providerLogout = await provider.buildLogoutUrl(idToken, home.href);
|
|
62
|
+
redirect(SEE_OTHER, (providerLogout ?? home).href, { external });
|
|
63
|
+
};
|
|
64
|
+
const routes = {
|
|
65
|
+
[LOGIN_PATH]: { method: 'GET', handle: login },
|
|
66
|
+
[CALLBACK_PATH]: { method: 'GET', handle: callback },
|
|
67
|
+
[LOGOUT_PATH]: { method: 'POST', handle: logout },
|
|
68
|
+
};
|
|
69
|
+
const handler = (event) => {
|
|
70
|
+
const route = routes[event.url.pathname.slice(basePath.length)];
|
|
71
|
+
if (!route)
|
|
72
|
+
error(NOT_FOUND);
|
|
73
|
+
if (event.request.method !== route.method)
|
|
74
|
+
return new Response(undefined, {
|
|
75
|
+
status: METHOD_NOT_ALLOWED,
|
|
76
|
+
headers: { allow: route.method },
|
|
77
|
+
});
|
|
78
|
+
return route.handle(event);
|
|
79
|
+
};
|
|
80
|
+
const loginRedirect = ({ request, url, isDataRequest }) => {
|
|
81
|
+
if (!isDataRequest && !isNavigation(request))
|
|
82
|
+
return undefined;
|
|
83
|
+
if (url.pathname === basePath || url.pathname.startsWith(`${basePath}/`))
|
|
84
|
+
return undefined;
|
|
85
|
+
const params = new URLSearchParams({ [RETURN_TO_PARAM]: `${url.pathname}${url.search}` });
|
|
86
|
+
return `${basePath}${LOGIN_PATH}?${params}`;
|
|
87
|
+
};
|
|
88
|
+
return { handler, loginRedirect, provider };
|
|
89
|
+
}
|
|
90
|
+
// HttpOnly y SameSite=Lax; Secure lo pone SvelteKit (salvo en http://localhost). Las mismas opciones
|
|
91
|
+
// para ponerla y para borrarla.
|
|
92
|
+
function cookie(name, path, maxAge) {
|
|
93
|
+
return { name, options: { path, httpOnly: true, sameSite: 'lax', maxAge } };
|
|
94
|
+
}
|
|
95
|
+
// Sin cookie, o rota: undefined (exchangeCode da NO_LOGIN_IN_PROGRESS)
|
|
96
|
+
function parsePendingLogin(value) {
|
|
97
|
+
if (!value)
|
|
98
|
+
return undefined;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(value);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Los errores de un +server.ts no pasan por +error.svelte: la página la arma el componente
|
|
107
|
+
function defaultLoginError(err, basePath) {
|
|
108
|
+
const { code, message, providerError } = err;
|
|
109
|
+
console.warn(`[oidc] ${code}: ${message}${providerError ? ` (${providerError})` : ''}`);
|
|
110
|
+
const html = `<!doctype html>
|
|
111
|
+
<html lang="es">
|
|
112
|
+
<meta charset="utf-8">
|
|
113
|
+
<title>No se pudo entrar</title>
|
|
114
|
+
<h1>No se pudo entrar</h1>
|
|
115
|
+
<p>El login falló (${code}); el detalle está en el log de la app.</p>
|
|
116
|
+
<p><a href="${basePath}${LOGIN_PATH}">Reintentar</a></p>
|
|
117
|
+
</html>`;
|
|
118
|
+
return new Response(html, {
|
|
119
|
+
status: UNAUTHORIZED,
|
|
120
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
121
|
+
});
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,109 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opensas/oidc-sveltekit-adapter",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Login por OpenID Connect (ADFS, Keycloak) para apps SvelteKit, con una capa genérica para cualquier app Node",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24.0.0 <25"
|
|
7
|
+
},
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"!dist/**/*.test.*",
|
|
14
|
+
"!dist/**/*.spec.*",
|
|
15
|
+
"!dist/**/fixtures.*",
|
|
16
|
+
"!dist/server"
|
|
17
|
+
],
|
|
18
|
+
"sideEffects": [
|
|
19
|
+
"**/*.css"
|
|
20
|
+
],
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"svelte",
|
|
31
|
+
"sveltekit",
|
|
32
|
+
"oidc",
|
|
33
|
+
"openid-connect",
|
|
34
|
+
"adfs",
|
|
35
|
+
"keycloak"
|
|
36
|
+
],
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://gitlab.com/trabajo-it/comun/oidc-sveltekit-adapter.git"
|
|
40
|
+
},
|
|
41
|
+
"imports": {
|
|
42
|
+
"#lib": "./src/lib/index.js",
|
|
43
|
+
"#lib/*": "./src/lib/*",
|
|
44
|
+
"#e2e/*": "./e2e/*.ts"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@sveltejs/kit": "^3.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@eslint/js": "^10.0.1",
|
|
51
|
+
"@playwright/test": "^1.64.1",
|
|
52
|
+
"@sveltejs/adapter-node": "^6.0.1",
|
|
53
|
+
"@sveltejs/kit": "^3.1.0",
|
|
54
|
+
"@sveltejs/package": "^3.0.0",
|
|
55
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
|
56
|
+
"@types/node": "^24.19.2",
|
|
57
|
+
"eslint": "^10.13.0",
|
|
58
|
+
"eslint-config-prettier": "^10.1.8",
|
|
59
|
+
"eslint-plugin-check-file": "^3.3.2",
|
|
60
|
+
"eslint-plugin-simple-import-sort": "^14.0.0",
|
|
61
|
+
"eslint-plugin-svelte": "^3.23.1",
|
|
62
|
+
"eslint-plugin-unicorn": "^71.1.0",
|
|
63
|
+
"globals": "^17.13.0",
|
|
64
|
+
"prettier": "^3.9.10",
|
|
65
|
+
"prettier-plugin-svelte": "^4.1.0",
|
|
66
|
+
"publint": "^0.3.25",
|
|
67
|
+
"shiki": "^4.6.0",
|
|
68
|
+
"svelte": "^5.57.2",
|
|
69
|
+
"svelte-check": "^4.7.7",
|
|
70
|
+
"typescript": "^6.0.3",
|
|
71
|
+
"typescript-eslint": "^8.72.0",
|
|
72
|
+
"vite": "^8.3.5",
|
|
73
|
+
"vitest": "^4.1.12"
|
|
74
|
+
},
|
|
75
|
+
"dependencies": {
|
|
76
|
+
"openid-client": "^6.8.9"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"dev": "NODE_EXTRA_CA_CERTS=docker/keycloak/certs/localhost.crt vite dev",
|
|
80
|
+
"build": "vite build && pnpm prepack",
|
|
81
|
+
"preview": "NODE_EXTRA_CA_CERTS=docker/keycloak/certs/localhost.crt vite preview",
|
|
82
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
83
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
84
|
+
"format": "prettier --write --cache --log-level warn .",
|
|
85
|
+
"lint": "eslint . --cache",
|
|
86
|
+
"lint:clean": "node -e \"for (const p of ['.eslintcache', 'node_modules/.cache/prettier']) require('node:fs').rmSync(p, { recursive: true, force: true })\"",
|
|
87
|
+
"verify": "eslint . --fix --cache && pnpm format && pnpm check",
|
|
88
|
+
"up:safe": "pnpm --config.minimumReleaseAge=1440 up -i",
|
|
89
|
+
"sv:sync-deps": "node scripts/sv-sync-deps.ts",
|
|
90
|
+
"keycloak:cert": "sh scripts/keycloak-cert.sh",
|
|
91
|
+
"keycloak:start": "pnpm keycloak:cert && docker compose -f docker/keycloak/docker-compose.yaml up -d",
|
|
92
|
+
"keycloak:stop": "docker compose -f docker/keycloak/docker-compose.yaml stop",
|
|
93
|
+
"keycloak:status": "docker ps -a --filter name=oidc-sveltekit-adapter-keycloak --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'",
|
|
94
|
+
"keycloak:logs": "docker compose -f docker/keycloak/docker-compose.yaml logs -f",
|
|
95
|
+
"keycloak:purge": "docker compose -f docker/keycloak/docker-compose.yaml down -v",
|
|
96
|
+
"keycloak:init": "node scripts/keycloak-init.ts",
|
|
97
|
+
"test": "pnpm test:vite && pnpm test:play",
|
|
98
|
+
"test:unit": "vitest run --project=unit-tests",
|
|
99
|
+
"test:vite": "vitest run",
|
|
100
|
+
"test:e2e": "playwright test --project=e2e-tests --pass-with-no-tests",
|
|
101
|
+
"test:play": "playwright test --pass-with-no-tests",
|
|
102
|
+
"test:ci": "node scripts/test-ci.ts",
|
|
103
|
+
"vm:pack": "node scripts/vm-pack.ts",
|
|
104
|
+
"gitlab-runner:start": "sudo gitlab-runner start",
|
|
105
|
+
"gitlab-runner:stop": "sudo gitlab-runner stop",
|
|
106
|
+
"gitlab-runner:status": "sudo gitlab-runner status",
|
|
107
|
+
"gitlab-runner:logs": "sudo journalctl -u gitlab-runner -f"
|
|
108
|
+
}
|
|
6
109
|
}
|