@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 CHANGED
@@ -1,3 +1,216 @@
1
- # Temporary Holding Version
1
+ # oidc-sveltekit-adapter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
@@ -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,3 @@
1
+ import type { Claims } from './types.ts';
2
+ export type Permissions = Record<string, string[]>;
3
+ export declare function getPermissions(accessTokenClaims: Claims | undefined, clientId: string): Permissions;
@@ -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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }