[PR #566] [MERGED] feat: JWT bearer assertions for client authentication #725

Closed
opened 2025-10-07 00:21:18 +03:00 by OVERLORD · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/pocket-id/pocket-id/pull/566
Author: @ItalyPaleAle
Created: 5/25/2025
Status: Merged
Merged: 6/6/2025
Merged by: @stonith404

Base: mainHead: client-assertions-2


📝 Commits (10+)

📊 Changes

38 files changed (+1460 additions, -289 deletions)

View changed files

📝 .gitignore (+1 -0)
📝 backend/go.mod (+5 -5)
📝 backend/go.sum (+10 -10)
📝 backend/internal/bootstrap/e2etest_router_bootstrap.go (+8 -1)
📝 backend/internal/bootstrap/services_bootstrap.go (+9 -5)
📝 backend/internal/common/errors.go (+5 -0)
📝 backend/internal/controller/e2etest_controller.go (+50 -5)
📝 backend/internal/controller/oidc_controller.go (+9 -7)
📝 backend/internal/dto/dto_mapper.go (+53 -0)
📝 backend/internal/dto/oidc_dto.go (+31 -16)
📝 backend/internal/model/oidc.go (+44 -2)
📝 backend/internal/service/app_config_service.go (+3 -3)
📝 backend/internal/service/app_config_service_test.go (+14 -66)
📝 backend/internal/service/e2etest_service.go (+129 -18)
📝 backend/internal/service/jwt_service.go (+2 -55)
📝 backend/internal/service/jwt_service_test.go (+2 -1)
📝 backend/internal/service/oidc_service.go (+225 -41)
backend/internal/service/oidc_service_test.go (+365 -0)
backend/internal/service/testutils_test.go (+97 -0)
backend/internal/utils/jwk_util.go (+69 -0)

...and 18 more files

📄 Description

Fixes #361

TLDR

  • Implements support for authenticating OAuth2 clients using JWT bearer client assertions in lieu of a client secret
  • Complies with RFC 7523

Goals

Full discussion is in #361, but to recap...

We want to enable confidential clients (OAuth2 applications) to perform a token exchange without having to use a client secret, because client secrets are long-lived shared secrets and are not the best solution for security.

  • When clients (apps) exchange an auth code for an access token, by invoking the /api/oidc/token endpoint, normally they include a client ID and client secret. With this change, instead of a client secret they can include client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer (this is a constant defined in the RFC) and a client_assertion=... containing a JWT signed by an external identity server, which is issued to the application to identify itself
  • For example, apps that run on cloud services could use things like Azure managed identities or AWS IAM service roles. Apps that run on Kubernetes can use a (bound) service account token. Or it could be a token issued with SPIRE. (Note: doesn't mean Pocket ID must run on the cloud or on K8s, it's independent from where Pocket ID is hosted)

References

How this works

Users should be able to add one or more federated identities for each app in Pocket ID. For each federated identity they'll need to provide:

  • The issuer (Required), which is the iss for the external identity service
  • The subject, which defaults to the client ID for the OAuth client (but can be set to a different value, e.g. could be a SPIFFE ID when using SPIFFE/SPIRE)
  • The audience for the token, which by default is the URL of the Pocket ID server
  • The URL of the JWKS, which by default is <iss>/.well-known/jwks.json

Then, OAuth2 apps will be able to present as client_assertion JWTs that are signed by the issuer above, and which have the subject and audience configured as per above.

How you can test this

Credentials:

  • Issuer: https://alessandrosegala.blob.core.windows.net/
  • JWKS URI: https://alessandrosegala.blob.core.windows.net/$root/jwks.json

To test it:

  1. Create a new OAuth application in Pocket ID. Use the values above for Issuer and JWKS URI. Use api://PocketIDTokenExchange for audience and 123456abcdef for subject.
  2. Start the OAuth authorization flow (I've used oauth.tools but any other way works) and obtain an access code
  3. When you get the access code, you can use one of the tokens below and curl to exchange it for an auth token:
CODE=your-access-code
CLIENT_ID=your-client-id
REDIRECT_URI=your-redirect-uri
curl -Ss -X POST \
http://localhost:1411/api/oidc/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code&redirect_uri='${REDIRECT_URI}'code&code='${CODE}'&client_id='${CLIENT_ID}'&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion='${CLIENT_ASSERTION}

Use one of these values for CLIENT_ASSERTION:

# Valid token
CLIENT_ASSERTION=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkZaU1Z3WkU3aUxTek1jcnhRb2lOOWRjcEphS2VNUzdTQnE5eFN6SVhLLTQifQ.eyJzdWIiOiIxMjM0NTZhYmNkZWYiLCJpc3MiOiJodHRwczovL2FsZXNzYW5kcm9zZWdhbGEuYmxvYi5jb3JlLndpbmRvd3MubmV0LyIsImlhdCI6MTc0ODE0NzI1NiwiYXVkIjoiYXBpOi8vUG9ja2V0SURUb2tlbkV4Y2hhbmdlIiwiZXhwIjoxNzQ5MjQ3MjU2fQ.ZBTPYFxe4X2qHic4t-P09kfgfCf0x9FfMSlH2zc_2dadAQ9rZDKGTekAuSZ9sL7FofrxzElh_tUKAYV0ScpKcnQJY-t7gB1bT6jdevndmTUmBOa8NcFfSSAxMyBxzuD-OAzhQG78JMk_Gf4LJVLwyXL7Ngz_feM7o3zxI0TRPBlurfE500PPwpfhZB90ggEiMtxBLHFws0rbUat98FBOcsKdjqkp9p0NsLuzDtxM2W1nOJ_LXk9rOKPb5naTKHzgajWOV9yg1PSx_vxqkFVH9BV9byimgLc1BKXRxKfm79YrRo3CWmJ-FvcIkpvAkgOdqi6KiZ2KpZSPJjH8sCYKXw

# Fails - invalid subject
CLIENT_ASSERTION=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkZaU1Z3WkU3aUxTek1jcnhRb2lOOWRjcEphS2VNUzdTQnE5eFN6SVhLLTQifQ.eyJzdWIiOiJCQUQiLCJpc3MiOiJodHRwczovL2FsZXNzYW5kcm9zZWdhbGEuYmxvYi5jb3JlLndpbmRvd3MubmV0LyIsImlhdCI6MTc0ODE0NzI1NiwiYXVkIjoiYXBpOi8vUG9ja2V0SURUb2tlbkV4Y2hhbmdlIiwiZXhwIjoxNzQ5MjQ3MjU2fQ.RtpSGnJ0w45Epr8p6nRrA0dLe-V69HU3RwRfEvjffApYkM3ln-rkyeVWjzJIZ-qpe4yQG5UtkkOk4mMrb-L0_WRDP25p_bdCu42UTa_B3OpAATkTfWlGI2HWBr4w3ExUkLFK7UXfs0l5A11huEbbSQwqsBuk3rTkeWwlE8pq8RAjZbVTkpezMHBt-L9L-eeDmiX71ZxYFuUnQ557geyhh0guO17OcVaTbDiSKNkkQZQHOZhs2Yy8aVjeJgBoUDAJwlIdRI4O_JhqdlZgk88HHFl47akNom4sLfyFmo8ez3BD89-IINuQnY27LWT9sHiTNi0o3-6bA_bilNgpwHsFXw

# Fails - invalid audience
CLIENT_ASSERTION=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkZaU1Z3WkU3aUxTek1jcnhRb2lOOWRjcEphS2VNUzdTQnE5eFN6SVhLLTQifQ.eyJzdWIiOiIxMjM0NTZhYmNkZWYiLCJpc3MiOiJodHRwczovL2FsZXNzYW5kcm9zZWdhbGEuYmxvYi5jb3JlLndpbmRvd3MubmV0LyIsImlhdCI6MTc0ODE0NzI1NiwiYXVkIjoiYXBpOi8vQkFEIiwiZXhwIjoxNzQ5MjQ3MjU2fQ.S3gAzBKH9JPF0bbr2qgI6WI29YCXtipsVTNhIKMlgwzPHIILR3maDM2e8_jQyuayZhpHeGXa7eTrNyVhKBksmfhkM6Js_Vmv-L1Q6Oc0FkTjMJvTWC_H7G6a1O5eo3jlWICVfSk6sHOG8mosCBNA0B3xZVrs5H5MPm5Of9AEIrtAo6VbtRo6fkwCmDxm6fzeTtws3g7WlWrHjQZcGrKbXo44Hzsp6WPwmCF8Xp9GgkRbympshizJSewRQsBnoWE0xQ7KFmTLhvmBTrwiQ-ifWrre1VoqCOjuoGOoMEnxPlVYcHBurdeK5VHsxgm9BwgmxThjrt2KVc2SQ-DvWLkEpw

# Fails - signed by an invalid key
CLIENT_ASSERTION=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkZaU1Z3WkU3aUxTek1jcnhRb2lOOWRjcEphS2VNUzdTQnE5eFN6SVhLLTQifQ.eyJzdWIiOiIxMjM0NTZhYmNkZWYiLCJpc3MiOiJodHRwczovL2FsZXNzYW5kcm9zZWdhbGEuYmxvYi5jb3JlLndpbmRvd3MubmV0LyIsImlhdCI6MTc0ODE0NzI1NiwiYXVkIjoiYXBpOi8vUG9ja2V0SURUb2tlbkV4Y2hhbmdlIiwiZXhwIjoxNzQ5MjQ3MjU2fQ.EnrllAAKlOSv_3odLItIQHx28HwbV84RhHY5Hfh95eb8gzvUWYHR_vAuIj91hY5oyEtOn6qM3WQRRdWAWPoOcuxbVSW09TG6MXA6DZo7Pd43_IkWAovH5qen7uIb0wiv_3Vf3dls_YRzuHd5Mo2wdIKVmYJSrg22Qd5x6clcDXhwrfO_5B1H8WLlV-KaiEMBjN-7YFh59Eb4wbHrNaXrFyYWjuD-PZeG1NH9aSFBNeOq0KArjYIvn96mjhlKiE_DT15CK1zPjndikbK_SwAovOgGCV4Jnazm0_XF9AOCuPNEJlKwb0MhhcNcsr3Aelo9_3zxLKbe8DbEgI0I4ZjWYw

🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/pocket-id/pocket-id/pull/566 **Author:** [@ItalyPaleAle](https://github.com/ItalyPaleAle) **Created:** 5/25/2025 **Status:** ✅ Merged **Merged:** 6/6/2025 **Merged by:** [@stonith404](https://github.com/stonith404) **Base:** `main` ← **Head:** `client-assertions-2` --- ### 📝 Commits (10+) - [`6546467`](https://github.com/pocket-id/pocket-id/commit/65464672cfb74ca23d97586d78a243d23735be2e) WIP: backend implementation - [`dfd5217`](https://github.com/pocket-id/pocket-id/commit/dfd5217de42de2e741fabf60ae8728bfdca617c7) Fixes - [`736d567`](https://github.com/pocket-id/pocket-id/commit/736d5678ac01db6b21b3312368308503a4c8613b) Make most fields default - [`87f9752`](https://github.com/pocket-id/pocket-id/commit/87f97527082dc8a21b34eaa6e4a6b60f04a4d3f7) Use default audience as Pocket ID AppURL - [`4236fd7`](https://github.com/pocket-id/pocket-id/commit/4236fd798bbddc27981a76c4c27be1f65c2ef0d9) Lint - [`cebd618`](https://github.com/pocket-id/pocket-id/commit/cebd61884d1423f91290cc200948a868dcecdeb3) generalized test util newDatabaseForTest - [`01bed5d`](https://github.com/pocket-id/pocket-id/commit/01bed5df74a3661ff84dac9714aa8f0e3d1247d3) scaffolding tests - [`9efda6e`](https://github.com/pocket-id/pocket-id/commit/9efda6ef6a7718dfeffb12062b4354a3d0270b21) CreateClient/UpdateClient can now set credentials - [`d0a175a`](https://github.com/pocket-id/pocket-id/commit/d0a175a6f7678f13bc6e6e37beecfa5bdeff231d) Add tests - [`d9b3dc1`](https://github.com/pocket-id/pocket-id/commit/d9b3dc18f12439ce1a0c594a1f19a20a082d5588) More tests ### 📊 Changes **38 files changed** (+1460 additions, -289 deletions) <details> <summary>View changed files</summary> 📝 `.gitignore` (+1 -0) 📝 `backend/go.mod` (+5 -5) 📝 `backend/go.sum` (+10 -10) 📝 `backend/internal/bootstrap/e2etest_router_bootstrap.go` (+8 -1) 📝 `backend/internal/bootstrap/services_bootstrap.go` (+9 -5) 📝 `backend/internal/common/errors.go` (+5 -0) 📝 `backend/internal/controller/e2etest_controller.go` (+50 -5) 📝 `backend/internal/controller/oidc_controller.go` (+9 -7) 📝 `backend/internal/dto/dto_mapper.go` (+53 -0) 📝 `backend/internal/dto/oidc_dto.go` (+31 -16) 📝 `backend/internal/model/oidc.go` (+44 -2) 📝 `backend/internal/service/app_config_service.go` (+3 -3) 📝 `backend/internal/service/app_config_service_test.go` (+14 -66) 📝 `backend/internal/service/e2etest_service.go` (+129 -18) 📝 `backend/internal/service/jwt_service.go` (+2 -55) 📝 `backend/internal/service/jwt_service_test.go` (+2 -1) 📝 `backend/internal/service/oidc_service.go` (+225 -41) ➕ `backend/internal/service/oidc_service_test.go` (+365 -0) ➕ `backend/internal/service/testutils_test.go` (+97 -0) ➕ `backend/internal/utils/jwk_util.go` (+69 -0) _...and 18 more files_ </details> ### 📄 Description Fixes #361 ## TLDR - Implements support for authenticating OAuth2 clients using JWT bearer client assertions in lieu of a client secret - Complies with [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) ## Goals Full discussion is in #361, but to recap... We want to enable confidential clients (OAuth2 applications) to perform a token exchange without having to use a client secret, because client secrets are long-lived shared secrets and are not the best solution for security. - When clients (apps) exchange an auth code for an access token, by invoking the `/api/oidc/token` endpoint, normally they include a client ID and client secret. With this change, instead of a client secret they can include `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer` (this is a constant defined in the RFC) and a `client_assertion=...` containing a JWT signed by an **external** identity server, which is issued to the application to identify itself - For example, apps that run on cloud services could use things like Azure managed identities or AWS IAM service roles. Apps that run on Kubernetes can use a (bound) service account token. Or it could be a token issued with SPIRE. (Note: doesn't mean Pocket ID must run on the cloud or on K8s, it's independent from where Pocket ID is hosted) ## References - RFC 7523, specifically [section 2.2](https://datatracker.ietf.org/doc/html/rfc7523#section-2.2) ("Using JWTs for client authentication"), and additional requirements in section 3 - Additional context from [IETF draft for Workload Identity Practices](https://www.ietf.org/archive/id/draft-ietf-wimse-workload-identity-practices-00.html) - Used the Microsoft Entra ID implementation as reference implementation ([docs](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-access-token-with-a-certificate-credential)) ## How this works Users should be able to add one or more federated identities for each app in Pocket ID. For each federated identity they'll need to provide: - The issuer (Required), which is the `iss` for the external identity service - The subject, which defaults to the client ID for the OAuth client (but can be set to a different value, e.g. could be a SPIFFE ID when using SPIFFE/SPIRE) - The audience for the token, which by default is the URL of the Pocket ID server - The URL of the JWKS, which by default is `<iss>/.well-known/jwks.json` Then, OAuth2 apps will be able to present as `client_assertion` JWTs that are signed by the issuer above, and which have the subject and audience configured as per above. ## How you can test this Credentials: - Issuer: `https://alessandrosegala.blob.core.windows.net/` - JWKS URI: `https://alessandrosegala.blob.core.windows.net/$root/jwks.json` To test it: 1. Create a new OAuth application in Pocket ID. Use the values above for Issuer and JWKS URI. Use `api://PocketIDTokenExchange` for audience and `123456abcdef` for subject. 2. Start the OAuth authorization flow (I've used [oauth.tools](https://oauth.tools/) but any other way works) and obtain an access code 3. When you get the access code, you can use one of the tokens below and curl to exchange it for an auth token: ```sh CODE=your-access-code CLIENT_ID=your-client-id REDIRECT_URI=your-redirect-uri curl -Ss -X POST \ http://localhost:1411/api/oidc/token \ -H 'Content-Type: application/x-www-form-urlencoded' \ -d 'grant_type=authorization_code&redirect_uri='${REDIRECT_URI}'code&code='${CODE}'&client_id='${CLIENT_ID}'&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&client_assertion='${CLIENT_ASSERTION} ``` Use one of these values for `CLIENT_ASSERTION`: ```sh # Valid token CLIENT_ASSERTION=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkZaU1Z3WkU3aUxTek1jcnhRb2lOOWRjcEphS2VNUzdTQnE5eFN6SVhLLTQifQ.eyJzdWIiOiIxMjM0NTZhYmNkZWYiLCJpc3MiOiJodHRwczovL2FsZXNzYW5kcm9zZWdhbGEuYmxvYi5jb3JlLndpbmRvd3MubmV0LyIsImlhdCI6MTc0ODE0NzI1NiwiYXVkIjoiYXBpOi8vUG9ja2V0SURUb2tlbkV4Y2hhbmdlIiwiZXhwIjoxNzQ5MjQ3MjU2fQ.ZBTPYFxe4X2qHic4t-P09kfgfCf0x9FfMSlH2zc_2dadAQ9rZDKGTekAuSZ9sL7FofrxzElh_tUKAYV0ScpKcnQJY-t7gB1bT6jdevndmTUmBOa8NcFfSSAxMyBxzuD-OAzhQG78JMk_Gf4LJVLwyXL7Ngz_feM7o3zxI0TRPBlurfE500PPwpfhZB90ggEiMtxBLHFws0rbUat98FBOcsKdjqkp9p0NsLuzDtxM2W1nOJ_LXk9rOKPb5naTKHzgajWOV9yg1PSx_vxqkFVH9BV9byimgLc1BKXRxKfm79YrRo3CWmJ-FvcIkpvAkgOdqi6KiZ2KpZSPJjH8sCYKXw # Fails - invalid subject CLIENT_ASSERTION=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkZaU1Z3WkU3aUxTek1jcnhRb2lOOWRjcEphS2VNUzdTQnE5eFN6SVhLLTQifQ.eyJzdWIiOiJCQUQiLCJpc3MiOiJodHRwczovL2FsZXNzYW5kcm9zZWdhbGEuYmxvYi5jb3JlLndpbmRvd3MubmV0LyIsImlhdCI6MTc0ODE0NzI1NiwiYXVkIjoiYXBpOi8vUG9ja2V0SURUb2tlbkV4Y2hhbmdlIiwiZXhwIjoxNzQ5MjQ3MjU2fQ.RtpSGnJ0w45Epr8p6nRrA0dLe-V69HU3RwRfEvjffApYkM3ln-rkyeVWjzJIZ-qpe4yQG5UtkkOk4mMrb-L0_WRDP25p_bdCu42UTa_B3OpAATkTfWlGI2HWBr4w3ExUkLFK7UXfs0l5A11huEbbSQwqsBuk3rTkeWwlE8pq8RAjZbVTkpezMHBt-L9L-eeDmiX71ZxYFuUnQ557geyhh0guO17OcVaTbDiSKNkkQZQHOZhs2Yy8aVjeJgBoUDAJwlIdRI4O_JhqdlZgk88HHFl47akNom4sLfyFmo8ez3BD89-IINuQnY27LWT9sHiTNi0o3-6bA_bilNgpwHsFXw # Fails - invalid audience CLIENT_ASSERTION=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkZaU1Z3WkU3aUxTek1jcnhRb2lOOWRjcEphS2VNUzdTQnE5eFN6SVhLLTQifQ.eyJzdWIiOiIxMjM0NTZhYmNkZWYiLCJpc3MiOiJodHRwczovL2FsZXNzYW5kcm9zZWdhbGEuYmxvYi5jb3JlLndpbmRvd3MubmV0LyIsImlhdCI6MTc0ODE0NzI1NiwiYXVkIjoiYXBpOi8vQkFEIiwiZXhwIjoxNzQ5MjQ3MjU2fQ.S3gAzBKH9JPF0bbr2qgI6WI29YCXtipsVTNhIKMlgwzPHIILR3maDM2e8_jQyuayZhpHeGXa7eTrNyVhKBksmfhkM6Js_Vmv-L1Q6Oc0FkTjMJvTWC_H7G6a1O5eo3jlWICVfSk6sHOG8mosCBNA0B3xZVrs5H5MPm5Of9AEIrtAo6VbtRo6fkwCmDxm6fzeTtws3g7WlWrHjQZcGrKbXo44Hzsp6WPwmCF8Xp9GgkRbympshizJSewRQsBnoWE0xQ7KFmTLhvmBTrwiQ-ifWrre1VoqCOjuoGOoMEnxPlVYcHBurdeK5VHsxgm9BwgmxThjrt2KVc2SQ-DvWLkEpw # Fails - signed by an invalid key CLIENT_ASSERTION=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkZaU1Z3WkU3aUxTek1jcnhRb2lOOWRjcEphS2VNUzdTQnE5eFN6SVhLLTQifQ.eyJzdWIiOiIxMjM0NTZhYmNkZWYiLCJpc3MiOiJodHRwczovL2FsZXNzYW5kcm9zZWdhbGEuYmxvYi5jb3JlLndpbmRvd3MubmV0LyIsImlhdCI6MTc0ODE0NzI1NiwiYXVkIjoiYXBpOi8vUG9ja2V0SURUb2tlbkV4Y2hhbmdlIiwiZXhwIjoxNzQ5MjQ3MjU2fQ.EnrllAAKlOSv_3odLItIQHx28HwbV84RhHY5Hfh95eb8gzvUWYHR_vAuIj91hY5oyEtOn6qM3WQRRdWAWPoOcuxbVSW09TG6MXA6DZo7Pd43_IkWAovH5qen7uIb0wiv_3Vf3dls_YRzuHd5Mo2wdIKVmYJSrg22Qd5x6clcDXhwrfO_5B1H8WLlV-KaiEMBjN-7YFh59Eb4wbHrNaXrFyYWjuD-PZeG1NH9aSFBNeOq0KArjYIvn96mjhlKiE_DT15CK1zPjndikbK_SwAovOgGCV4Jnazm0_XF9AOCuPNEJlKwb0MhhcNcsr3Aelo9_3zxLKbe8DbEgI0I4ZjWYw ``` --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
OVERLORD added the pull-request label 2025-10-07 00:21:19 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: starred/pocket-id#725