Sign-in with bjungle — el OIDC bridge sobre el wallet SSI
Este conteúdo não está disponível em sua língua ainda.
El wallet SSI bjungle ya es portable: un usuario que se onboardeó en
una fintech puede aprobar consents para otra y compartir su identidad
sin volver a escanear el DNI. Pero hoy ese flujo requiere que el segundo
tenant llame nuestra API (request-presentation) y maneje webhooks.
Es robusto pero no es lo que los developers esperan.
Lo que los developers esperan es el botón “Sign in with Google” — un standard OAuth2 / OIDC que su backend ya soporta y que cualquier librería web puede consumir en 10 líneas.
Este ADR fija cómo bjungle expone el wallet como un OpenID Provider estándar, manteniendo SSI por debajo. El resultado es que un nuevo tenant integra el botón “Sign in with bjungle” exactamente igual que “Sign in with Google”, y los datos vienen de VCs firmadas en lugar de un user-DB centralizado.
Diagrama del flujo
Sección titulada «Diagrama del flujo»┌─────────────────────────────────────────────────────────────────┐│ Carolina-Pay (relying party — RP) NO conocía al usuario antes ││ ┌──────────────────────────────────────┐ ││ │ Botón: "Sign in with bjungle" │ ││ └──────────────────────────────────────┘ │└─────────────────────────────────────────────────────────────────┘ │ ▼ redirect con client_id + scopes┌─────────────────────────────────────────────────────────────────┐│ login.bjungle.com/authorize ││ 1. RP signature verified contra client_id/secret ││ 2. ¿Usuario logueado en bjungle? si no → /login (wallet) ││ 3. Pantalla de consent: "Carolina-Pay quiere ver: ││ - full_name ││ - is_adult ││ Recibirás 5 Açaí por aprobar" ││ 4. Usuario aprueba con passkey (+ face si trust policy lo pide)│└─────────────────────────────────────────────────────────────────┘ │ ▼ redirect con code + state┌─────────────────────────────────────────────────────────────────┐│ Carolina-Pay backend ││ 5. POST login.bjungle.com/token ││ grant_type=authorization_code ││ client_id + client_secret + code + redirect_uri ││ 6. Recibe id_token (JWT firmado RS256 con KMS) + access_token ││ 7. (opcional) GET /userinfo con access_token para claims extra ││ 8. Crea sesión local del usuario y lo redirige a su app │└─────────────────────────────────────────────────────────────────┘Visto desde el lado de Carolina-Pay, esto es OIDC vanilla. Cualquier SDK existente (passport-openidconnect, oidc-client-ts, NextAuth) habla con nosotros sin código custom.
Por debajo del capó
Sección titulada «Por debajo del capó»Lo que diferencia a bjungle de Google OIDC es de dónde vienen los claims y qué controla el usuario.
GOOGLE BJUNGLE──────────────────────────────── ────────────────────────────────claims = user-DB de Google claims = VCs en el wallet del userquien controla = Google quien controla = el usuariosi Google muere → todos pierden si bjungle muere → VCs siguen verificables contra did:web mientras el DNS existarevocar acceso = settings Google revocar acceso = revoke grant en wallet PWAmodelo económico = ads + GCP fees modelo económico = Açaí 20/7/5 por reusoEl id_token que emitimos:
{ "iss": "https://bjungle.com", "sub": "wallet:bafy...", // wallet_id, no PII directa "aud": "carolina_pay_client_id", "exp": 1748678400, "iat": 1748674800, "auth_time": 1748674790, "amr": ["webauthn", "face"], // mecanismos usados "acr": "urn:bjungle:loa:2", // LoA del VC fuente
// claims del VC "name": "Diego Pérez Martínez", "given_name": "Diego", "family_name": "Pérez Martínez", "birthdate": "1992-04-15", "preferred_username": null,
// claim no-estándar de bjungle: la prueba criptográfica "bjungle_vc": "eyJhbGc...", // el JWT-VC W3C subyacente "bjungle_grant_id": "01J5XXXX..." // referencia al grant en bmonkey}Los campos bjungle_* son no-estándar pero útiles si el RP quiere
verificar la VC directamente o registrar el grant para auditoría.
Endpoints estándar OIDC
Sección titulada «Endpoints estándar OIDC»Todos viven bajo https://login.bjungle.com/. En dev: :8081/oidc/.
| Endpoint | Método | Función |
|---|---|---|
/.well-known/openid-configuration | GET | Discovery — describe todos los endpoints + algorithms |
/.well-known/jwks.json | GET | Public keys (RS256 vía KMS) para verificar id_token |
/authorize | GET | Inicia el flow — devuelve consent UI + redirige con code |
/token | POST | Intercambia code por id_token + access_token |
/userinfo | GET | Claims actuales del usuario (con access_token) |
/end_session | GET | Logout — invalida session + redirige al RP |
/revoke | POST | Revoca token activo (RFC 7009) |
Discovery (/.well-known/openid-configuration)
Sección titulada «Discovery (/.well-known/openid-configuration)»{ "issuer": "https://bjungle.com", "authorization_endpoint": "https://login.bjungle.com/authorize", "token_endpoint": "https://login.bjungle.com/token", "userinfo_endpoint": "https://login.bjungle.com/userinfo", "jwks_uri": "https://login.bjungle.com/.well-known/jwks.json", "end_session_endpoint": "https://login.bjungle.com/end_session", "revocation_endpoint": "https://login.bjungle.com/revoke",
"scopes_supported": ["openid", "profile", "email", "phone", "bjungle:document", "bjungle:age_check"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "id_token_signing_alg_values_supported": ["RS256"], "subject_types_supported": ["pairwise"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic", "private_key_jwt"], "code_challenge_methods_supported": ["S256"],
"acr_values_supported": ["urn:bjungle:loa:1", "urn:bjungle:loa:2", "urn:bjungle:loa:3"],
"claims_supported": [ "sub", "iss", "aud", "exp", "iat", "auth_time", "amr", "acr", "name", "given_name", "family_name", "birthdate", "email", "email_verified", "phone_number", "phone_number_verified", "bjungle_vc", "bjungle_grant_id" ]}Scope mapping a claims
Sección titulada «Scope mapping a claims»Por design, el RP pide scopes (intent del nivel de acceso), no claims directos. bjungle deriva los claims:
| Scope | Claims emitidos |
|---|---|
openid | sub, iss, aud, exp, iat, auth_time, amr, acr (obligatorio) |
profile | name, given_name, family_name, birthdate |
email | email, email_verified |
phone | phone_number, phone_number_verified |
bjungle:document | bjungle_document_type, bjungle_document_number (alto sensibilidad — review por bjungle) |
bjungle:age_check | bjungle_is_adult: boolean (sin revelar birthdate completo) |
bjungle:age_check es un ejemplo de claim mínimo derivado — el RP
sabe que la persona es mayor sin tener que ver su fecha de nacimiento.
Esto es lo más fuerte de SSI vs OIDC tradicional.
Registro de clients (relying parties)
Sección titulada «Registro de clients (relying parties)»Hay tres tiers de RP:
Tier 1 · Tenant del ecosistema
Sección titulada «Tier 1 · Tenant del ecosistema»Tenants ya registrados en bjungle (Carolina-Pay) tienen client_id + client_secret derivados de su API key. Sus redirect_uri se whitelistéan en el portal:
tenant-portal :4401 → tab "SSO" → "Configurar Sign-in with bjungle" → input redirect_uris (lista whitelisted) → genera client_id + client_secret → copy snippet de integraciónTier 2 · External SaaS
Sección titulada «Tier 2 · External SaaS»Empresas que NO son tenants pero quieren usar identidad bjungle como proveedor. Registro autoservicio:
developers.bjungle.com → "Crear app" → email + descripción de la app + redirect_uris → email verification → revisión manual (puede ser instantánea para apps low-risk) → genera client_id + client_secretPricing: pagan Açaí por sesión exitosa, exactamente igual que tenants del ecosistema.
Tier 3 · Confidential clients (banca, gov)
Sección titulada «Tier 3 · Confidential clients (banca, gov)»Aplicaciones de alto riesgo que requieren private_key_jwt en lugar de
client_secret. Registro manual con KYB completa.
Diferencias clave con OIDC vanilla
Sección titulada «Diferencias clave con OIDC vanilla»bjungle es OIDC compatible pero con cinco extensiones SSI que conviene documentar:
1. El sub es el wallet_id
Sección titulada «1. El sub es el wallet_id»sub no identifica una “cuenta de bjungle”. Identifica el wallet
SSI. Si el usuario migra entre devices, el sub es estable. Si pierde
el wallet (sin recovery), el sub cambia.
Para evitar correlación entre RPs distintos, soportamos subject_types : pairwise — cada RP recibe un sub diferente para el mismo usuario.
2. acr expresa LoA
Sección titulada «2. acr expresa LoA»acr = “Authentication Context Class Reference” — estándar OIDC para
expresar “qué tan fuerte” fue el login. Mappeamos a LoA:
urn:bjungle:loa:1 ←→ consent + email_otp (NIST IAL1)urn:bjungle:loa:2 ←→ + document + face_match (NIST IAL2)urn:bjungle:loa:3 ←→ + liveness + sarlaft + sms_otp (NIST IAL3)Si Carolina-Pay pide acr_values=urn:bjungle:loa:3 y el usuario solo
tiene LoA2, bjungle muestra UI de “tenés que completar más KYC” y le
da el path para hacerlo.
3. amr lista los factores
Sección titulada «3. amr lista los factores»amr = “Authentication Methods References”. Standard:
"webauthn" ← passkey usado"face" ← face-MFA usado (ADR-002)"otp" ← OTP por email/sms"pwd" ← password (legacy, raramente)"mfa" ← múltiples factores combinados4. bjungle_vc claim para verificación criptográfica
Sección titulada «4. bjungle_vc claim para verificación criptográfica»El RP que quiere prueba criptográfica fuerte (no solo confiar en
bjungle como issuer) puede tomar bjungle_vc del id_token y verificar
el JWT-VC firmado por el did:web:bmonkey.bjungle.com directamente
contra el JWKS. Útil para:
- Audit logs que necesitan no-repudio.
- Apps que despliegan offline (validan VC en otro momento).
- Apps que quieren mostrar la VC al usuario en su propia UI.
5. Açaí emission en background
Sección titulada «5. Açaí emission en background»A diferencia de Google que es “gratis para el RP, costoso en privacidad para el usuario”, bjungle cobra al RP por sesión y reparte:
RP pagó: 32 Açaí ≈ COP 1900 → 20 Açaí a bjungle (platform fee) → 7 Açaí al tenant origen (quien hizo KYC original del usuario) → 5 Açaí al usuario (data dividend)El reparto es automático en el outbox al consumir el code.
Roadmap de implementación
Sección titulada «Roadmap de implementación»Fase 1 — Endpoints core (~1 semana) - Tabla oidc_clients (client_id, client_secret_hash, redirect_uris, scopes) - Tabla oidc_codes (code_hash, client_id, wallet_id, scopes, nonce, exp) - Tabla oidc_tokens (access_token_hash, refresh_token_hash, exp) - GET /.well-known/openid-configuration - GET /.well-known/jwks.json - GET /authorize → consent UI (reusa el wallet PWA component) - POST /token (code grant + refresh_token grant) - GET /userinfo
Fase 2 — Portal del relying party (~3 días) - tenant-portal tab "SSO" → registro de redirect_uris - developers.bjungle.com landing + auto-signup external - SDK JS @bjungle/sign-in (botón ready-to-use)
Fase 3 — Hardening (~3 días) - PKCE obligatorio para public clients - private_key_jwt para confidential - rate limiting por client_id - rotation automática de client_secret cada 180 díasAnti-patrones
Sección titulada «Anti-patrones»- Tratar el id_token como Long-Lived session. id_token tiene exp corto (15 min default), no debe almacenarse persistente. Usar access_token + refresh para sesiones largas.
- Pedir todos los scopes en cada login. UX malo y baja conversion. Pedir mínimo necesario en login; pedir más en step-up cuando hace falta.
- Skipear verificación de id_token porque “viene de bjungle”. El RP DEBE verificar firma (jwks_uri), iss, aud, exp en cada login.
- Reusar el código (code) más de una vez. Es single-use por spec OIDC; bjungle marca consumed_at y rechaza repetidos.
Implicaciones para otros ADRs
Sección titulada «Implicaciones para otros ADRs»- ADR-001 audiencias: sign-in with bjungle es para end users primero, operadores segundo. Staff bjungle interno usa OIDC corporate (Google Workspace), no este.
- ADR-002 face-MFA: el RP puede pedir
acr_values=urn:bjungle:loa:3que activa face-MFA durante el authorize. Elamrresultante incluyeface. - ADR-006 flow composition: el RP no se preocupa por el flow — solo pide acr. bjungle determina qué VC del usuario satisface ese acr o lanza KYC adicional.
- Wallet Modelo C: claves nunca salen del
device. id_token se firma con KMS RSA del issuer (
did:web:bmonkey.bjungle.com), no con la clave del usuario. La VC adjunta (bjungle_vc) es lo firmado por el issuer.
MVP entregado (2026-05-30)
Sección titulada «MVP entregado (2026-05-30)»Lo que está cerrado en este sprint es el OIDC bridge backend de Tier 1 (tenants ya registrados). Los Tiers 2 y 3, el SDK JS y los endpoints de revoke/end_session están enumerados en DEFERRED.
- Migration 0017
oidc_bridge— 4 tablas con RLS estricto:oidc_clients(client_id, client_secret_hash, redirect_uris, scopes, tenant_id, audit columns).oidc_authorize_requests(state machine del flow de authorize: persistencia de PKCE challenge, nonce, scopes solicitados).oidc_codes(code_hash, client_id, wallet_id, scopes, exp, consumed_at, redirect_uri). Single-use conFOR UPDATElock.oidc_access_tokens(token_hash, client_id, wallet_id, scopes, exp). Opacos hex 32 bytes, hash sha256 en DB.
- Helpers
SECURITY DEFINERpara todas las mutations + seed del client de devtenant_portal_devconredirect_uriswhitelistadas paralocalhost:4401.
Endpoints
Sección titulada «Endpoints»| Endpoint | Función |
|---|---|
GET /.well-known/openid-configuration | discovery vanilla |
GET /.well-known/jwks.json | public key del KMS issuer |
GET /v1/oidc/authorize | inicia flow, valida client + PKCE, redirige a consent UI |
POST /v1/oidc/consent | aprueba/deniega — escribe code + redirige al RP |
POST /v1/oidc/token | intercambia code por id_token + access_token |
GET /v1/oidc/userinfo | claims con access_token Bearer |
POST /v1/oidc/clients | (admin) registra Tier 1 clients para un tenant |
Garantías criptográficas y de protocolo
Sección titulada «Garantías criptográficas y de protocolo»- PKCE S256 obligatorio.
code_challenge_method=plain→ 400. Authorize sincode_challenge→ 400 (no se redirige con error porque el client no es trusted hasta que pase PKCE — RFC 7636). - id_token RS256 firmado por el VC issuer existente
(
alias/bjungle-bmonkey-issueren KMS),kid: bmonkey-default,iss: did:web:bmonkey.bjungle.com. Reutiliza el mismo material que firma JWT-VCs del wallet → un único trust anchor. - Access tokens opacos hex 32 bytes generados con
crypto/rand.Read. Solo el hash sha256 en DB; el plaintext sale en la response y no se persiste. TTL 1h. - Codes single-use, TTL 10 min, con
FOR UPDATElock al consumir para evitar race en double-exchange. Reused code → 400invalid_grant.
Security findings cerrados (round 2)
Sección titulada «Security findings cerrados (round 2)»| ID | Finding | Fix |
|---|---|---|
| DJ-PT-49-01 | confused-deputy: code emitido por client A redimible por client B | binding client_id validado en /token contra oidc_codes.client_id (RFC 6749 §4.1.3) |
| DJ-PT-49-02 | open-redirect en deny path del consent | redirige solo a redirect_uris validados; deny path normaliza con url.Parse y descarta query foránea |
| DJ-PT-49-03 | bootstrap token compare timing-vulnerable | subtle.ConstantTimeCompare en requireBootstrapToken |
| DJ-PT-49-04 | CSP form-action * global filtraba la consent UI | CSP localizado en /consent con form-action 'self' (no afecta el resto del HTML del wallet) |
Validación operacional
Sección titulada «Validación operacional»- Build Go verde.
- E2E spec
12-oidc.spec.ts: 10/10 pass — discovery, JWKS, happy path completo (authorize → consent → token → userinfo), PKCE enforcement (sin challenge, con method=plain), expired code, wrong verifier (con rollback del code), redirect_uri mismatch, code reuse, unknown client_id. - Audit log: cada uno de los pasos del flow escribe entries (
oidc.authorize.begin,oidc.consent.approve|deny,oidc.token.exchange,oidc.userinfo.read).
DEFERRED (fuera de MVP de Fase 2)
Sección titulada «DEFERRED (fuera de MVP de Fase 2)»Items que el ADR contempla pero que NO entran al sprint actual:
POST /v1/oidc/revoke(RFC 7009) — revocación explícita de access_token / refresh_token por parte del RP. Hoy la rotación se hace por expiración. Sub-PR dedicado.GET /v1/oidc/end_session— logout del IdP + redirect alpost_logout_redirect_uridel RP. Sub-PR dedicado.- Refresh tokens — implementación del grant
refresh_token. El discovery los anuncia pero el handler de/tokensolo soportaauthorization_codepor ahora. - Confidential clients con
private_key_jwt— Tier 3 (banca, gov). Hoy soloclient_secret_postyclient_secret_basic. Falta el path de JWKS por client + verificación de assertion. - SDK JS
@bjungle/sign-in— wrapper del flow con botón listo para usar. Sub-PR de frontend + paquete npm. - Tier 2 (external SaaS) y Tier 3 (confidential) — registro
autoservicio en
developers.bjungle.com+ KYB para Tier 3. Hoy solo Tier 1 (tenants ya registrados) puede crear clients víaPOST /v1/oidc/clients. - Pairwise
subclaim — anti-correlación entre RPs. Hoy elsubes elwallet_iddirecto. El discovery anunciasubject_types_supported: ["pairwise"]como roadmap.
Decisión
Sección titulada «Decisión»| Aspecto | Decisión |
|---|---|
| Status | Accepted (2026-05-30) |
| Aplicable a | bmonkey-api · platform-api · wallet PWA · developers.bjungle.com |
| Algoritmo JWT | RS256 (KMS) — alineado con W3C VC issuer existente |
| Issuer DID | did:web:bmonkey.bjungle.com |
kid | bmonkey-default |
| Access token | opaco hex 32 bytes, hash sha256 en DB, TTL 1h |
| Code | single-use, TTL 10 min, FOR UPDATE lock al consumir |
| PKCE | S256 obligatorio; plain rechazado con 400 |
| Encriptación id_token | no por default; opcional para confidential clients |
| Re-evaluar | cuando entremos a federar con consortium IdPs (e.g. eIDAS Eu wallets) |
Fase 3 entregada (2026-05-30)
Sección titulada «Fase 3 entregada (2026-05-30)»F3.1 · Sign-in with bjungle end-to-end + F3.3 · OIDC token lifecycle hardening.
| Componente | Status | Notas |
|---|---|---|
SDK JS @bjungle/sign-in v0.1.0 | ✅ entregado | PKCE + React button hook + 3 examples (plain-html, vite-react, nextjs). 28 tests vitest passing. |
Portal tab /settings/sso | ✅ entregado | CRUD redirect_uris + regenerate-secret (stub backend deferred). |
| Operator session token (HS256) | ✅ entregado | POST /v1/oidc/callback/operator-session mints short-lived JWT (15min). tenancy.MiddlewareWithOperatorSession acepta tanto X-API-Key como Authorization: Bearer. Migration 0020_tenant_operators + 0021_operator_session_hardening. |
| Super-admin path closed | ✅ entregado | select-tenant.tsx lista multi-tenant memberships; el requested_tenant_id body field rechaza tenants no autorizados. |
POST /v1/oidc/revoke (RFC 7009) | ✅ entregado | F3.3 service OIDCTokensService.Revoke, idempotent, audit log oidc.token.revoked. Migration 0024_oidc_tokens. |
GET /v1/oidc/end_session | ✅ entregado | RP-Initiated Logout; valida id_token_hint con iss/aud/sig (DJ-PT-F3.3-07). Cookie clear + revoke refresh chain. |
| Refresh tokens con rotation + replay detection | ✅ entregado | OIDCTokensService.RefreshGrant rota el token; replay (token revoked + reused) → revoca toda la chain. Audit oidc.refresh.replay_detected/repeat/use_after_revoke (DJ-PT-F3.3-10). |
| E2E specs 14 (portal OIDC 10 specs) + 17 (revoke 5 specs) + 18 (refresh 5 specs) | ✅ entregado | Wave 3 qa rondas pasadas. |
DEFERRED a Wave 5:
- Confidential clients + client_secret_jwt — JWKS-based mTLS / private_key_jwt.
- Tier 2/3 client registration — hoy solo Tier 1 (tenants registrados).
- Pairwise
subclaim — anti-correlación entre RPs. - Integración refresh_token grant en handler T49
/v1/oidc/token— actualmente F3.3 token handler comentado enWire()para no duplicar la ruta. Consolidación deOIDCService+OIDCTokensServicependiente. - DJ-PT-F3.3-18: JWKS local cache (hoy
fetchIssuerPublicKeyhace GET por request).