Ir al contenido

Sign-in with bjungle — el OIDC bridge sobre el wallet SSI

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.

┌─────────────────────────────────────────────────────────────────┐
│ 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.

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 user
quien controla = Google quien controla = el usuario
si Google muere → todos pierden si bjungle muere → VCs siguen verificables
contra did:web mientras el DNS exista
revocar acceso = settings Google revocar acceso = revoke grant en wallet PWA
modelo económico = ads + GCP fees modelo económico = Açaí 20/7/5 por reuso

El 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.

Todos viven bajo https://login.bjungle.com/. En dev: :8081/oidc/.

EndpointMétodoFunción
/.well-known/openid-configurationGETDiscovery — describe todos los endpoints + algorithms
/.well-known/jwks.jsonGETPublic keys (RS256 vía KMS) para verificar id_token
/authorizeGETInicia el flow — devuelve consent UI + redirige con code
/tokenPOSTIntercambia code por id_token + access_token
/userinfoGETClaims actuales del usuario (con access_token)
/end_sessionGETLogout — invalida session + redirige al RP
/revokePOSTRevoca 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"
]
}

Por design, el RP pide scopes (intent del nivel de acceso), no claims directos. bjungle deriva los claims:

ScopeClaims emitidos
openidsub, iss, aud, exp, iat, auth_time, amr, acr (obligatorio)
profilename, given_name, family_name, birthdate
emailemail, email_verified
phonephone_number, phone_number_verified
bjungle:documentbjungle_document_type, bjungle_document_number (alto sensibilidad — review por bjungle)
bjungle:age_checkbjungle_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.

Hay tres tiers de RP:

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ón

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_secret

Pricing: pagan Açaí por sesión exitosa, exactamente igual que tenants del ecosistema.

Aplicaciones de alto riesgo que requieren private_key_jwt en lugar de client_secret. Registro manual con KYB completa.

bjungle es OIDC compatible pero con cinco extensiones SSI que conviene documentar:

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.

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.

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 combinados

4. 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.

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.

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ías
  • 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.
  • 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:3 que activa face-MFA durante el authorize. El amr resultante incluye face.
  • 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.

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 con FOR UPDATE lock.
    • oidc_access_tokens (token_hash, client_id, wallet_id, scopes, exp). Opacos hex 32 bytes, hash sha256 en DB.
  • Helpers SECURITY DEFINER para todas las mutations + seed del client de dev tenant_portal_dev con redirect_uris whitelistadas para localhost:4401.
EndpointFunción
GET /.well-known/openid-configurationdiscovery vanilla
GET /.well-known/jwks.jsonpublic key del KMS issuer
GET /v1/oidc/authorizeinicia flow, valida client + PKCE, redirige a consent UI
POST /v1/oidc/consentaprueba/deniega — escribe code + redirige al RP
POST /v1/oidc/tokenintercambia code por id_token + access_token
GET /v1/oidc/userinfoclaims con access_token Bearer
POST /v1/oidc/clients(admin) registra Tier 1 clients para un tenant
  • PKCE S256 obligatorio. code_challenge_method=plain → 400. Authorize sin code_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-issuer en 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 UPDATE lock al consumir para evitar race en double-exchange. Reused code → 400 invalid_grant.
IDFindingFix
DJ-PT-49-01confused-deputy: code emitido por client A redimible por client Bbinding client_id validado en /token contra oidc_codes.client_id (RFC 6749 §4.1.3)
DJ-PT-49-02open-redirect en deny path del consentredirige solo a redirect_uris validados; deny path normaliza con url.Parse y descarta query foránea
DJ-PT-49-03bootstrap token compare timing-vulnerablesubtle.ConstantTimeCompare en requireBootstrapToken
DJ-PT-49-04CSP form-action * global filtraba la consent UICSP localizado en /consent con form-action 'self' (no afecta el resto del HTML del wallet)
  • 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).

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 al post_logout_redirect_uri del RP. Sub-PR dedicado.
  • Refresh tokens — implementación del grant refresh_token. El discovery los anuncia pero el handler de /token solo soporta authorization_code por ahora.
  • Confidential clients con private_key_jwt — Tier 3 (banca, gov). Hoy solo client_secret_post y client_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ía POST /v1/oidc/clients.
  • Pairwise sub claim — anti-correlación entre RPs. Hoy el sub es el wallet_id directo. El discovery anuncia subject_types_supported: ["pairwise"] como roadmap.
AspectoDecisión
StatusAccepted (2026-05-30)
Aplicable abmonkey-api · platform-api · wallet PWA · developers.bjungle.com
Algoritmo JWTRS256 (KMS) — alineado con W3C VC issuer existente
Issuer DIDdid:web:bmonkey.bjungle.com
kidbmonkey-default
Access tokenopaco hex 32 bytes, hash sha256 en DB, TTL 1h
Codesingle-use, TTL 10 min, FOR UPDATE lock al consumir
PKCES256 obligatorio; plain rechazado con 400
Encriptación id_tokenno por default; opcional para confidential clients
Re-evaluarcuando entremos a federar con consortium IdPs (e.g. eIDAS Eu wallets)

F3.1 · Sign-in with bjungle end-to-end + F3.3 · OIDC token lifecycle hardening.

ComponenteStatusNotas
SDK JS @bjungle/sign-in v0.1.0✅ entregadoPKCE + React button hook + 3 examples (plain-html, vite-react, nextjs). 28 tests vitest passing.
Portal tab /settings/sso✅ entregadoCRUD redirect_uris + regenerate-secret (stub backend deferred).
Operator session token (HS256)✅ entregadoPOST /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✅ entregadoselect-tenant.tsx lista multi-tenant memberships; el requested_tenant_id body field rechaza tenants no autorizados.
POST /v1/oidc/revoke (RFC 7009)✅ entregadoF3.3 service OIDCTokensService.Revoke, idempotent, audit log oidc.token.revoked. Migration 0024_oidc_tokens.
GET /v1/oidc/end_session✅ entregadoRP-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✅ entregadoOIDCTokensService.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)✅ entregadoWave 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 sub claim — anti-correlación entre RPs.
  • Integración refresh_token grant en handler T49 /v1/oidc/token — actualmente F3.3 token handler comentado en Wire() para no duplicar la ruta. Consolidación de OIDCService + OIDCTokensService pendiente.
  • DJ-PT-F3.3-18: JWKS local cache (hoy fetchIssuerPublicKey hace GET por request).