Skip to content

feat: add JWE (JSON Web Encryption) decryption support#209

Open
bvogel wants to merge 4 commits intoomniauth:masterfrom
UnidyID:feat/jwe-decryption-support
Open

feat: add JWE (JSON Web Encryption) decryption support#209
bvogel wants to merge 4 commits intoomniauth:masterfrom
UnidyID:feat/jwe-decryption-support

Conversation

@bvogel
Copy link

@bvogel bvogel commented Mar 5, 2026

Motivation

Some OpenID Connect providers mandate that ID tokens are wrapped in a JWE (JSON Web Encryption) envelope before being returned to the relying party. A notable example is the Belgian It'sMe identity provider, which requires RSA-OAEP-256 encrypted ID tokens per the OIDC spec (RFC 7516).

There is currently no way to use such providers with this gem.

What this adds

Two new strategy options:

Option Description
id_token_encryption_alg The key-wrapping algorithm: 'RSA-OAEP', 'RSA-OAEP-256', or 'dir'
id_token_encryption_key PEM string for RSA algorithms; raw bytes/string for 'dir'

decode_id_token is extended to transparently detect and decrypt a JWE envelope before passing the inner JWS to the existing verification logic. When id_token_encryption_alg is not configured the behavior is completely unchanged.

Supported algorithms

Key-wrapping Content encryption Implementation
RSA-OAEP A128GCM, A256GCM, A128CBC-HS256, A256CBC-HS512 json-jwt (already a transitive dep)
RSA-OAEP-256 A128GCM, A256GCM, A128CBC-HS256, A256CBC-HS512 Native OpenSSL (requires OpenSSL >= 3.0)
dir A128GCM, A256GCM, A128CBC-HS256, A256CBC-HS512 json-jwt

Note on RSA-OAEP-256: json-jwt does not support SHA-256 for the OAEP hash and MGF1 mask, so this path uses native OpenSSL. OpenSSL 3.0 added the rsa_oaep_md / rsa_mgf1_md options required for this. OpenSSL >= 3.0 ships by default with Ruby 3.1+. If RSA-OAEP-256 is requested on an older OpenSSL, a descriptive CallbackError is raised.

Error handling

All failure paths (missing key, malformed PEM, decryption failure, unsupported enc, bad base64) are caught and re-raised as CallbackError, preserving the existing error-handling contract in callback_phase.

Tests

New test file test/lib/omniauth/strategies/openid_connect_jwe_test.rb with 17 tests covering:

  • jwe? detection (with/without alg configured, 3-segment vs 5-segment)
  • decrypt_jwe for each alg: missing-key errors, delegation to json-jwt, round-trip decryption
  • RSA-OAEP-256 round-trips with A128GCM and A128CBC-HS256 enc
  • Error wrapping for DecryptionFailed, CipherError, ArgumentError
  • decode_id_token integration (decrypt called for JWE, skipped for JWS)

All 53 tests pass (36 existing + 17 new).

References

@bruno-
Copy link

bruno- commented Mar 5, 2026

This looks good to me. Only minor thing I noticed: how about updating the README the options that were added?

Overall, the approach seems solid 👍

@bvogel
Copy link
Author

bvogel commented Mar 5, 2026

This looks good to me. Only minor thing I noticed: how about updating the README the options that were added?

Excellent point, added a section to the README

end

def jwe?(token)
options.id_token_encryption_alg.to_s != '' && token.to_s.count('.') + 1 == JWE_SEGMENT_COUNT
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so it's always the same number of segments ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for encrypted? yes. Its always 3 for unencrypted and 5 for encrypted

Copy link

@Sokre95 Sokre95 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good !

@bvogel
Copy link
Author

bvogel commented Mar 10, 2026

We have this code in production for a customer integrating with It'sMe.

@bvogel bvogel force-pushed the feat/jwe-decryption-support branch from 0f1e4c4 to c3c7835 Compare March 10, 2026 15:00

option :logout_path, '/logout'
option :id_token_encryption_alg, nil # e.g. 'RSA-OAEP', 'RSA-OAEP-256', 'dir'
option :id_token_encryption_key, nil # PEM string for RSA algorithms; raw bytes/string for 'dir'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw bytes are a bit tricky to pass through as strings, especially if this config comes from JSON. Would it better to point this to a file?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, raw bytes are a pain, however our practical experience showed that in most cases the shared key is usually "just" a string, so it would be fine, but if the maintainers prefer we could opt for a base64 encoding or a file, but as said - our production experience works fine with this format.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend there be an option to use either a base64 version or a file. In my installations, we load OmniAuth configs from JSON, and raw bytes are not an option.

options.id_token_encryption_alg.to_s != '' && token.to_s.count('.') + 1 == JWE_SEGMENT_COUNT
end

def decrypt_jwe(jwe_token)
Copy link
Contributor

@stanhu stanhu Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be better to use the json-jwt or jose gem here?

  1. https://github.com/jwt/ruby-jwe
  2. https://github.com/potatosalad/ruby-jose

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We considered both and actually spent time trying to make just json-jwt work for dir before going with the custom path. In production (integrating with the Belgian identity provider It'sMe), we hit errors that we weren't able to fix. In the end we decided bypassing gems entirely for dir and implementing the AES-CBC/AES-GCM layer directly over OpenSSL — which is what decrypt_dir does now.

For RSA-OAEP, json-jwt works correctly and is already a transitive dependency via openid_connect, so we kept it there. RSA-OAEP-256 also required a custom path because json-jwt doesn't support it either.

As for ruby-jose: it would be the most complete single-gem solution and handles all of these cases well, but it's a non-trivial new dependency to impose on all consumers of this gem just for JWE support. Given that the custom code is narrow, well-tested, and avoids that dependency cost, we felt it was the right trade-off, but we're open to switching if the maintainers feel otherwise.

bvogel added 4 commits March 18, 2026 09:17
Adds support for providers that wrap the ID token in a JWE envelope
before returning it (e.g. the Belgian It'sMe identity provider mandates
RSA-OAEP-256 encrypted ID tokens). Previously there was no way to use
such providers with this gem.

New options:
- `id_token_encryption_alg` - the key-wrapping algorithm ('RSA-OAEP',
  'RSA-OAEP-256', or 'dir')
- `id_token_encryption_key` - PEM string for RSA algorithms; raw bytes
  for 'dir' (direct symmetric key agreement)

`decode_id_token` transparently decrypts the JWE envelope before
passing the inner JWS to the existing verification logic. When
`id_token_encryption_alg` is not set the behaviour is unchanged.

Supported algorithms:
- RSA-OAEP: delegated to the json-jwt gem (already a transitive dep)
- RSA-OAEP-256: custom OpenSSL path - requires OpenSSL >= 3.0
- dir: delegated to the json-jwt gem

Supported content encryption: A128GCM, A256GCM, A128CBC-HS256,
A256CBC-HS512.

All error paths (missing key, bad PEM, decryption failure, unknown enc)
are wrapped in CallbackError to preserve the existing error-handling
contract.

Ref: RFC 7516 (JSON Web Encryption)
@bvogel bvogel force-pushed the feat/jwe-decryption-support branch from 78e8d74 to fc600c2 Compare March 18, 2026 08:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants