@dotrino/identity 0.86.1 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/package.json +1 -1
- package/src/index.d.ts +2 -2
- package/src/index.js +8 -2
- package/vault/core.js +38 -9
- package/vault/vault.js +8 -5
package/README.md
CHANGED
|
@@ -168,10 +168,20 @@ ceremonia, no seguridad. Cualquier **dato tuyo** —nombre, foto, correo, enlace
|
|
|
168
168
|
cosa: hace falta que lo concedas a ESE origen, y lo concedido se guarda y se puede retirar.
|
|
169
169
|
|
|
170
170
|
```js
|
|
171
|
-
await id.listGrants() // [{ origin, scopes, at }]
|
|
171
|
+
await id.listGrants() // [{ origin, scopes, at, lastUsed, onBehalfOf? }]
|
|
172
172
|
await id.revokeGrant(origin) // y la próxima vez se vuelve a preguntar
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
+
`lastUsed` se apunta cada vez que ese origen pide una prueba, **aunque solo pida el
|
|
176
|
+
mínimo**: entrar es usar, lleve datos o no. Sin ese dato, «dónde se usó mi identidad» solo
|
|
177
|
+
podría decir qué concediste, no si sigue usándose — que es lo que hace que uno se decida a
|
|
178
|
+
retirar un permiso que ya no hace falta.
|
|
179
|
+
|
|
180
|
+
`onBehalfOf` lo declara el origen cuando pide **por otro** (lo usa el puente OIDC, que pide
|
|
181
|
+
por una aplicación de fuera; sin esto todas ellas se verían como una sola entrada). Es una
|
|
182
|
+
afirmación suya, no un hecho comprobable, así que se enseña siempre subordinada al origen —
|
|
183
|
+
«Tal App · a través de sso.dotrino.com»— y nunca en su lugar.
|
|
184
|
+
|
|
175
185
|
**El panel lo pinta la bóveda, no la aplicación.** Vive en otro origen, así que la página
|
|
176
186
|
que pide no puede pulsar ahí dentro ni leerlo; lo único que puede hacer es no mostrarlo, y
|
|
177
187
|
entonces no consigue el permiso — que es el lado correcto en el que fallar. Sin nadie a
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -123,9 +123,9 @@ export class Identity {
|
|
|
123
123
|
removeContact (publickey: string): Promise<PeerInfo | null>
|
|
124
124
|
listContacts (): Promise<PeerInfo[]>
|
|
125
125
|
signData (data: any): Promise<{ signature: string; publickey: string }>
|
|
126
|
-
requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number }): Promise<Assertion>
|
|
126
|
+
requestAssertion (args: { audience: string; nonce: string; scopes?: AssertionScope[]; ttlMs?: number; onBehalfOf?: string }): Promise<Assertion>
|
|
127
127
|
/** Qué le has concedido a cada aplicación (permiso por origen). */
|
|
128
|
-
listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number }>>
|
|
128
|
+
listGrants (): Promise<Array<{ origin: string; scopes: AssertionScope[]; at: number; lastUsed: number; onBehalfOf?: string }>>
|
|
129
129
|
/** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
|
|
130
130
|
revokeGrant (origin: string): Promise<{ ok: boolean }>
|
|
131
131
|
setMyNickname (nickname: string): Promise<{ me: Me }>
|
package/src/index.js
CHANGED
|
@@ -277,8 +277,14 @@ export class Identity {
|
|
|
277
277
|
/** Retirar lo concedido a un origen: la próxima vez que pida, se vuelve a preguntar. */
|
|
278
278
|
async revokeGrant (origin) { return this._call('revokeGrant', { origin }) }
|
|
279
279
|
|
|
280
|
-
async requestAssertion ({ audience, nonce, scopes, ttlMs } = {}) {
|
|
281
|
-
|
|
280
|
+
async requestAssertion ({ audience, nonce, scopes, ttlMs, onBehalfOf } = {}) {
|
|
281
|
+
// ESPERA LO QUE TARDE UNA PERSONA. Los cinco segundos de siempre valen para una
|
|
282
|
+
// llamada que solo hace cuentas; esta puede abrir la pantalla de permiso (§permiso por
|
|
283
|
+
// origen) y ahí quien contesta es un humano. Con el plazo corto, el cliente se rendía
|
|
284
|
+
// con «Vault timeout» mientras el usuario todavía miraba el panel — y pulsar «Permitir»
|
|
285
|
+
// ya no servía de nada. El tope es un poco mayor que el del propio panel, para que el
|
|
286
|
+
// error que llegue sea «no lo concedió», que dice lo que pasó.
|
|
287
|
+
const { assertion } = await this._call('requestAssertion', { audience, nonce, scopes, ttlMs, onBehalfOf }, 65000)
|
|
282
288
|
return assertion
|
|
283
289
|
}
|
|
284
290
|
|
package/vault/core.js
CHANGED
|
@@ -1415,31 +1415,48 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1415
1415
|
* lo que ya estuviera concedido y nada más. **No se amplía en silencio**: sin respuesta,
|
|
1416
1416
|
* la respuesta es no.
|
|
1417
1417
|
*/
|
|
1418
|
-
async function consentFor (origin, pedidos) {
|
|
1418
|
+
async function consentFor (origin, pedidos, onBehalfOf = null) {
|
|
1419
1419
|
const org = String(origin || '').trim()
|
|
1420
|
+
// APUNTA CUÁNDO SE USÓ. Sin esto, «dónde se usó mi identidad» solo puede decir qué
|
|
1421
|
+
// concediste, no si sigue usándose — y eso es lo que hace que uno se decida a retirar
|
|
1422
|
+
// un permiso que ya no hace falta. Se apunta aunque solo se pida el mínimo: entrar es
|
|
1423
|
+
// usar, lleve datos o no.
|
|
1424
|
+
const marcarUso = () => {
|
|
1425
|
+
if (!org) return
|
|
1426
|
+
const g = loadGrants()
|
|
1427
|
+
const prev = g[org] || { scopes: [], at: Date.now() }
|
|
1428
|
+
g[org] = { ...prev, lastUsed: Date.now(), ...(onBehalfOf ? { onBehalfOf: String(onBehalfOf).slice(0, 60) } : {}) }
|
|
1429
|
+
saveGrants(g)
|
|
1430
|
+
}
|
|
1420
1431
|
const base = pedidos.filter((s) => s === 'id:whoami')
|
|
1421
1432
|
const extra = pedidos.filter((s) => s !== 'id:whoami')
|
|
1422
|
-
if (!extra.length) return pedidos
|
|
1433
|
+
if (!extra.length) { marcarUso(); return pedidos }
|
|
1423
1434
|
// Sin origen no se puede llevar la cuenta de a quién se le concedió qué, así que no se
|
|
1424
1435
|
// concede nada más que el mínimo. Es el caso de Node y el de una llamada interna.
|
|
1425
1436
|
if (!org) return base.length ? base : ['id:whoami']
|
|
1426
1437
|
|
|
1427
1438
|
const yaTiene = grantedTo(org)
|
|
1428
1439
|
const faltan = extra.filter((s) => !yaTiene.includes(s))
|
|
1429
|
-
if (!faltan.length) return pedidos
|
|
1440
|
+
if (!faltan.length) { marcarUso(); return pedidos }
|
|
1430
1441
|
|
|
1431
1442
|
if (typeof askConsent !== 'function') {
|
|
1432
1443
|
const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
|
|
1444
|
+
marcarUso()
|
|
1433
1445
|
return conocidos.length ? conocidos : ['id:whoami']
|
|
1434
1446
|
}
|
|
1435
1447
|
let ok = false
|
|
1436
|
-
try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene })) } catch (_) { ok = false }
|
|
1448
|
+
try { ok = !!(await askConsent({ origin: org, scopes: faltan, already: yaTiene, onBehalfOf })) } catch (_) { ok = false }
|
|
1437
1449
|
if (!ok) {
|
|
1438
1450
|
const conocidos = [...base, ...extra.filter((s) => yaTiene.includes(s))]
|
|
1451
|
+
marcarUso()
|
|
1439
1452
|
return conocidos.length ? conocidos : ['id:whoami']
|
|
1440
1453
|
}
|
|
1441
1454
|
const g = loadGrants()
|
|
1442
|
-
g[org] = {
|
|
1455
|
+
g[org] = {
|
|
1456
|
+
scopes: [...new Set([...yaTiene, ...faltan])].sort(),
|
|
1457
|
+
at: (g[org]?.at) || Date.now(), lastUsed: Date.now(),
|
|
1458
|
+
...(onBehalfOf ? { onBehalfOf: String(onBehalfOf).slice(0, 60) } : {})
|
|
1459
|
+
}
|
|
1443
1460
|
saveGrants(g)
|
|
1444
1461
|
return pedidos
|
|
1445
1462
|
}
|
|
@@ -1709,8 +1726,11 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1709
1726
|
*/
|
|
1710
1727
|
async listGrants () {
|
|
1711
1728
|
const g = loadGrants()
|
|
1712
|
-
return Object.entries(g).map(([origin, v]) => ({
|
|
1713
|
-
|
|
1729
|
+
return Object.entries(g).map(([origin, v]) => ({
|
|
1730
|
+
origin, scopes: v?.scopes || [], at: v?.at || 0,
|
|
1731
|
+
lastUsed: v?.lastUsed || v?.at || 0,
|
|
1732
|
+
...(v?.onBehalfOf ? { onBehalfOf: v.onBehalfOf } : {})
|
|
1733
|
+
})).sort((a, b) => b.lastUsed - a.lastUsed)
|
|
1714
1734
|
},
|
|
1715
1735
|
/** Retirar lo concedido a un origen. La próxima vez que pida, se vuelve a preguntar. */
|
|
1716
1736
|
async revokeGrant ({ origin } = {}) {
|
|
@@ -1721,13 +1741,22 @@ export async function createIdentityCore ({ kv: rawKv, peers, makeSync = null, k
|
|
|
1721
1741
|
return { ok: true }
|
|
1722
1742
|
},
|
|
1723
1743
|
|
|
1724
|
-
|
|
1744
|
+
/**
|
|
1745
|
+
* `onBehalfOf` es EN NOMBRE DE QUIÉN dice pedir el origen. Lo usa el puente OIDC, que
|
|
1746
|
+
* pide por una aplicación de fuera: sin esto, todas ellas se verían como una sola
|
|
1747
|
+
* entrada («Sso») y el usuario no sabría a quién le está dejando entrar.
|
|
1748
|
+
*
|
|
1749
|
+
* Es una AFIRMACIÓN del origen, no un hecho comprobable — y por eso se enseña siempre
|
|
1750
|
+
* subordinado a él («sso.dotrino.com dice: Tal App»), nunca en su lugar. Quien
|
|
1751
|
+
* responde por lo que pase sigue siendo el origen.
|
|
1752
|
+
*/
|
|
1753
|
+
async requestAssertion ({ audience, nonce, scopes, ttlMs, onBehalfOf, __origin } = {}) {
|
|
1725
1754
|
if (typeof audience !== 'string' || !audience.trim()) throw new Error('audience required')
|
|
1726
1755
|
if (typeof nonce !== 'string' || !nonce) throw new Error('nonce required')
|
|
1727
1756
|
const acta = loadActa()
|
|
1728
1757
|
// A NOMBRE DE QUIÉN va: la identidad es el `profileId`, no la llave de este aparato.
|
|
1729
1758
|
const sub = acta?.profileId || publickeyJwkStr
|
|
1730
|
-
const granted = await consentFor(__origin, cleanScopes(scopes))
|
|
1759
|
+
const granted = await consentFor(__origin, cleanScopes(scopes), onBehalfOf)
|
|
1731
1760
|
const permitido = claimsAllowed(granted)
|
|
1732
1761
|
const claims = {}
|
|
1733
1762
|
if (permitido.size) {
|
package/vault/vault.js
CHANGED
|
@@ -67,8 +67,8 @@ import { pubkeyId } from './capabilities.js'
|
|
|
67
67
|
const T_CONSENT = (() => {
|
|
68
68
|
const en = (navigator.language || 'es').startsWith('en')
|
|
69
69
|
return en
|
|
70
|
-
? { title: 'wants to see', allow: 'Allow', deny: 'No', who: 'Your Dotrino identity', once: 'Only what you allow leaves here.' }
|
|
71
|
-
: { title: 'quiere ver', allow: 'Permitir', deny: 'No', who: 'Tu identidad de Dotrino', once: 'De aquí solo sale lo que permitas.' }
|
|
70
|
+
? { title: 'wants to see', allow: 'Allow', deny: 'No', who: 'Your Dotrino identity', once: 'Only what you allow leaves here.', via: 'through' }
|
|
71
|
+
: { title: 'quiere ver', allow: 'Permitir', deny: 'No', who: 'Tu identidad de Dotrino', once: 'De aquí solo sale lo que permitas.', via: 'a través de' }
|
|
72
72
|
})()
|
|
73
73
|
const SCOPE_TXT = (() => {
|
|
74
74
|
const en = (navigator.language || 'es').startsWith('en')
|
|
@@ -85,6 +85,9 @@ import { pubkeyId } from './capabilities.js'
|
|
|
85
85
|
* No se importa el catálogo de aplicaciones: eso ataría este iframe al repositorio del
|
|
86
86
|
* home y habría que subirlo cada vez que nace una app. El subdominio ya es el nombre.
|
|
87
87
|
*/
|
|
88
|
+
/** Lo que dice el origen se pinta como texto, nunca como marcado: quien lo escribe es él. */
|
|
89
|
+
const escaparTexto = (t) => String(t).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
90
|
+
|
|
88
91
|
function nombreDeOrigen (origin) {
|
|
89
92
|
try {
|
|
90
93
|
const h = new URL(origin).hostname
|
|
@@ -96,7 +99,7 @@ import { pubkeyId } from './capabilities.js'
|
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
let consentAbierto = null
|
|
99
|
-
function askConsent ({ origin, scopes }) {
|
|
102
|
+
function askConsent ({ origin, scopes, onBehalfOf }) {
|
|
100
103
|
if (consentAbierto) return Promise.resolve(false) // una pregunta a la vez
|
|
101
104
|
return new Promise((resolve) => {
|
|
102
105
|
const host = document.createElement('div')
|
|
@@ -104,8 +107,8 @@ import { pubkeyId } from './capabilities.js'
|
|
|
104
107
|
const lista = scopes.map((x) => `<li>${SCOPE_TXT[x] || x}</li>`).join('')
|
|
105
108
|
host.innerHTML = `<div style="background:#171331;border:1px solid #2a2350;border-radius:16px;padding:22px;min-width:min(320px,90vw);max-width:90vw;color:#e7e3ff">
|
|
106
109
|
<div style="opacity:.7;font-size:13px">${T_CONSENT.who}</div>
|
|
107
|
-
<div style="font-weight:700;margin:8px 0 4px">${nombreDeOrigen(origin)} ${T_CONSENT.title}:</div>
|
|
108
|
-
<div style="opacity:.55;font-size:12px;margin-bottom:6px">${String(origin).replace(/^https?:\/\//, '')}</div>
|
|
110
|
+
<div style="font-weight:700;margin:8px 0 4px">${onBehalfOf ? escaparTexto(onBehalfOf) : nombreDeOrigen(origin)} ${T_CONSENT.title}:</div>
|
|
111
|
+
<div style="opacity:.55;font-size:12px;margin-bottom:6px">${onBehalfOf ? T_CONSENT.via + ' ' : ''}${String(origin).replace(/^https?:\/\//, '')}</div>
|
|
109
112
|
<ul style="margin:6px 0 12px 18px;padding:0">${lista}</ul>
|
|
110
113
|
<div style="opacity:.7;font-size:12px;margin-bottom:12px">${T_CONSENT.once}</div>
|
|
111
114
|
<div style="display:flex;gap:8px">
|