Repository navigation
[PHEE-371] Add migrated identity-provider as paymenthub-ee-auth (Java 21 / SB 3.4 / Jakarta) - #1
Merged
tdaly61 merged 6 commits intoAug 21, 2026
Conversation
GiulioRinalduzzi
force-pushed
the
feat/add-auth-identity-provider
branch
3 times, most recently
from
August 10, 2026 14:32
9bf9bda to
dd55863
Compare
… 21 / SB 3.4 / Jakarta) The auth service, migrated from openMF/ph-ee-identity-provider. It had not been touched since 2020: Spring Boot 1.5.22, Java 8, Maven, and an OAuth2 authorization server built on a library that no longer exists. Spring Boot 1.5.22 -> 3.4.4, Java 8 -> 21, javax.* -> jakarta.* in 29 files, EclipseLink 2.7.6 -> 4.0.4, Flyway 2.1.1 -> 10, Ehcache 2 -> Caffeine, Spring Data 1.x -> 3.x. Every version comes from org.mifos:paymenthub-ee-bom; none are pinned by hand. Maven -> Gradle, as done for ph-ee-exporter: the shared PHEE CircleCI template runs ./gradlew and the coming mifos-gradle-conventions plugins are Gradle only. The pom had to be rewritten from scratch either way. Its exclusion of hibernate-core from spring-boot-starter-data-jpa is kept: EclipseLink is the JPA provider here, and without it Hibernate 6.6 ships alongside it in the same jar. jakarta.transaction-api is now declared directly, because GroupRepository imports it and Spring Boot 1.5 was the last version whose data-jpa starter brought it in by itself. The bulk of the work is the authorization server, rebuilt on Spring Security 6. spring-security-oauth2 2.4.1 and spring-security-jwt were discontinued and have no Spring Boot 3 version, and their successor dropped the password grant this service exists to serve. So /oauth/token, /oauth/token_key and /oauth/check_token are re-implemented in TokenController with the same external contract: the same three grants, clients still read from oauth_client_details, the same RSA-signed JWTs with the same claims, and the same success and 401 bodies. This is the same replacement already written for ph-ee-operations-app, which validates these tokens, so the two agree claim by claim. AudienceVerifier moved from JwtClaimsSetVerifier to OAuth2TokenValidator, and PemUtils replaces the PEM parsing spring-security-jwt did. Bugs that only a running instance, or a second read, found: - spring.security.filter.order has to stay set. Spring Boot 3 renamed the key and made the default -100, which put the security chain before the tenant filter, so the audience check ran with no tenant and every valid token came back 401. - /error is permitAll: Spring Security also filters the ERROR dispatch, so the 400 the tenant filter sends for a missing Platform-TenantId was re-authorized there and became a 401. - CORS: the literal "*" origin with allowCredentials(true) has been rejected since Spring 5.3, so addAllowedOriginPattern. Under Spring 4.3 the old code worked and reflected the origin, so this restores the deployed behaviour. - /oauth/token and /oauth/token_key each get a security chain with no resource server on it. The bearer filter validates a token whenever the caller sends one, permitAll or not, and it stops the chain when that fails - so a client refreshing an expired access token, with that token still in the Authorization header, got a 401 from the endpoint that exists to recover from the expiry. The old @EnableAuthorizationServer kept these endpoints on their own chain, which had no bearer filter at all. - A Basic header that is not valid base64 raises IllegalArgumentException from the decoder, which was being answered with 400. It is a failed client authentication, so it answers 401 like the missing-colon case next to it. Flyway 10 cannot write to a Flyway 2.x schema_version table, so FlywayHistoryTableUpgrade converts it once before migrate(). It COPIES that table and never renames or drops it: this application is not always the only user of a schema - ph-ee-operations-app owns the history of the tenant schemas and still runs a Flyway 2 build - so schema_version can be another service's live table rather than a leftover. Copying also makes the conversion reversible. See the class comment for what this does not solve: the migration numbers themselves still overlap with operations-app, which predates this change and needs one owner for the tenant migrations. Cleanup from the reviews on the merged paymenthub-ee-* PRs: Jenkinsfile and docker-compose.yml removed (the latter pointed at the retired Azure registry and passed no profile, so it could not start), dead logback FILE appender removed, empty TestSomething removed, CircleCI added on JDK 21 (the repo had none), Dockerfile on eclipse-temurin:21-jre with a pinned app.jar and exec-form CMD. The application-large/med/bb.properties profiles are all kept: they are the only source of the tenants property, so dropping one makes that profile fail to start. Tests: 8 unit tests, up from one empty one. They cover the RSA key loading - the hand-written PKCS#1 to PKCS#8 wrapping every token depends on - and the token validators, including that a refresh token is refused as an access token. Client credentials can be sent as request parameters here, which the old stack did not allow (it never called allowFormAuthenticationForClients, so /oauth/token sat behind BasicAuthenticationFilter). It is kept - ph-ee-operations-app carries the same replacement and accepts it, and it widens nothing by itself - but it does mean client_secret can now be a request parameter, and PlatformRequestLog logs the parameter map. It is dropped there, next to password, so it is not written in clear at INFO on every token request. resolveClientCredentials says the same thing for whoever reads it next.
GiulioRinalduzzi
force-pushed
the
feat/add-auth-identity-provider
branch
from
August 10, 2026 15:09
dd55863 to
ab7a6b6
Compare
The tenant schemas already had this switch, one per tenant, in the auto_update column that flywayTenants() reads. The core schema had none, and it is the one that cannot recover: ph-ee-operations-app ships tenant_server_connections as V2 and this repository ships it as V1, so on a shared schema Flyway replays it out of order, the CREATE TABLE fails on a table that is already there, and flywayDefaultSchema() lets the exception escape - the application does not start. The same collision on a tenant schema is caught per tenant and only logged. fineract.datasource.core.auto-update, default true, so a deployment with a database of its own builds it exactly as before. Off, the service reads a schema it does not own: no migrations and no tenant registration, both of which write to the core schema. Everything it needs is already there in that case - m_appuser, m_role, m_permission and oauth_client_details all come from the owner. This does not renumber anything and does not decide who owns the migrations. It makes that decision expressible: without it there is no configuration in which this service starts against the core schema on gazelle. Verified against a MySQL 5.7 shaped like gazelle3 - core history holding V2__tenant-server-connections.sql, tenant history holding another service's migrations, auto_update 0 on the tenant row. With the flag on it fails to start with "Table 'tenant_server_connections' already exists", which is the bug. With it off the application starts, issues tokens and serves /api/v1/users for that tenant, and neither schema is touched: no flyway_schema_history is created and both legacy schema_version tables are left exactly as they were.
TokenController re-implements by hand what spring-security-oauth2 used to do, so nothing outside this repository pins its behaviour any more. It had no test: the 8 that existed cover PemUtils and the two validators, which are pure functions, and the endpoint itself was only ever checked by hand over HTTP. Every bug listed in the PR was found by running an instance, which is the argument for this. 15 tests through MockMvc, database and authentication manager stubbed, but the encoder and decoder are the real ones from ResourceServerConfig reading the real jwt.pem - the tokens here are the tokens the service issues. They pin what is easy to break and expensive to get wrong: the 400/401 split (a client that cannot authenticate gets 401, a grant type it may not use gets 400, as AuthExceptionTranslator did), the 401 body callers parse, the claims of an access token, and the four checks the refresh grant has to make for itself - the token belongs to the client presenting it, it really is a refresh token, the user still exists, the user is still enabled. Each of those four was mutation-checked, and two of the tests did not survive it: with the client-binding check removed, and with the refresh-claim check removed, they still passed - the request was refused a step later because the stubbed user lookup returned null, so they were green for the wrong reason. Both now stub an enabled user, so the only thing that can produce the 401 is the check under test. spring-boot-starter-test replaces the bare junit-jupiter dependency: JUnit 5, Mockito and spring-test, all versions from the BOM.
The switch only covered the core schema. Run against the real gazelle database with just that half skipped, Flyway finds a tenant schema of 255 tables with no history of its own, BASELINES it - creating a flyway_schema_history inside a schema owned by another service - then tries to apply V2 on tables that already exist and fails. What it leaves behind is a history table containing a failed row, which blocks Flyway for whoever does own that schema until someone runs repair() by hand. The per-tenant loop catches and only logs, so the service starts, the pod goes green, and the damage is one ERROR line in the log. The tenant schemas do have their own switch, the auto_update column, but it cannot carry this: it defaults to 1 - it is 1 for both tenants on gazelle - and it lives in tenant_server_connections, a table in the core schema this service does not own, so setting it to 0 means writing into the very schema we are trying not to touch. So one property now means one thing: either this service builds its own database, or it applies no migration anywhere and reads what the owner put there. Found by restoring the real gazelle dump (mifos-gazelle/config) instead of a reconstruction, which is also what turned up the numbers above: with the switch on, startup fails on the real tenants schema exactly as predicted, its single history row being V2__tenant-server-connections.sql. With the switch on both halves, the application starts and greenbank stays at 255 tables with no flyway_schema_history, bluebank stays empty, and tenants.schema_version is untouched.
GiulioRinalduzzi
force-pushed
the
feat/add-auth-identity-provider
branch
from
August 13, 2026 21:11
09da4b4 to
37cc4cd
Compare
David asked for these files to be the same across every new repo. Taken from ph-ee-start-here, with the repo-specific lines kept pointing at this repo. CODE_OF_CONDUCT.md replaced contributing.md replaces CONTRIBUTING.md security.md new, was missing README.md one link fixed, see below The seven clone/fork/remote URLs in contributing.md say paymenthub-ee-auth, not ph-ee-start-here. Everything else is byte-identical to the source. The CONTRIBUTING.md being replaced is the generic Mifos template: it points contributors at Jira project MXWAR and MXWAR-<ID> branch names, and still carries two unfilled placeholders. The new version is the Payment Hub one, pointing at PHEE and #payment-hub. The rename breaks a link in README.md, which said "See [CONTRIBUTING.md](CONTRIBUTING.md)". Blob paths on GitHub are case sensitive, so with the file now called contributing.md that link gives a 404 (checked, not assumed). The line points at the new name and, while there, at the new security policy, so all three documents are reachable from the README. LICENSE is left alone. The only difference from the source copy is line 360: this repo has https://mozilla.org/MPL/2.0/ and ph-ee-start-here has http://. Applying it would swap a secure link for a plain one, so the fix belongs in ph-ee-start-here instead.
GiulioRinalduzzi
force-pushed
the
feat/add-auth-identity-provider
branch
from
August 13, 2026 21:45
37cc4cd to
22b0c1a
Compare
tdaly61
requested changes
Aug 19, 2026
tdaly61
left a comment
Contributor
There was a problem hiding this comment.
@GiulioRinalduzzi , can you file tickets for the security items please , let me know when you have and I will approve ..but I don't want to loose what you have noted.
Contributor
Author
|
Ticket created: PHEE-405 |
tdaly61
approved these changes
Aug 21, 2026
tdaly61
left a comment
Contributor
There was a problem hiding this comment.
See PHEE-405 for future actions required and well flagged by Giulio in this PR
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The auth service, migrated from
openMF/ph-ee-identity-provider. This one had not been touched since 2020: Spring Boot 1.5.22, Java 8, Maven, and an OAuth2 authorization server built on a library that no longer exists.com.googlecode.flyway)Every version comes from
org.mifos:paymenthub-ee-bom. None are pinned by hand.Maven to Gradle, as done for
ph-ee-exporter: the shared PHEE CircleCI template runs./gradlewand the coming mifos-gradle-conventions plugins are Gradle only. The pom had to be rewritten from scratch either way, since Spring Boot 1.5 to 3.4 changes every dependency, so there was no small-diff option to preserve.The part worth a careful read: the authorization server
spring-security-oauth2 2.4.1andspring-security-jwtwere discontinued and have no Spring Boot 3 version. Their official successor,spring-authorization-server, dropped the password grant, which is what this service exists to serve. So the three endpoints are re-implemented by hand inTokenController, keeping the same external contract:password,refresh_token,client_credentialsoauth_client_detailstable of the current tenant, the same rows the oldJdbcClientDetailsServicereadjwt.pem/jwt_pub.pem, with the same claims (user_name,authorities,aud,scope,jti,client_id)This is not new code invented here: it is the same replacement already written for
ph-ee-operations-app, which validates the tokens this service issues, so the two agree claim by claim.Two behaviours were deliberately re-implemented rather than left to fall out:
authoritiesclaim, so without an explicit check one can be sent as a bearer token and authenticates with an empty authority list, for the whole refresh lifetime instead of the 10-minute access lifetime. There is a validator and a unit test for it.DefaultTokenServiceschecked ("Wrong client for this refresh token").AudienceVerifiermoved from the oldJwtClaimsSetVerifiertoOAuth2TokenValidator.PemUtilsreplaces the PEM parsingspring-security-jwtdid:jwt.pemis legacy PKCS#1, which the JDK cannot read directly, so it is wrapped into PKCS#8 in memory. No key file changed.Four bugs only a running instance found
Every valid token got a 401 on every protected endpoint. The tenant filter has to run before Spring Security so the audience check has a tenant to compare against. The old code arranged that with
security.filter-order=5; Spring Boot 3 renamed the key tospring.security.filter.orderand changed the default to-100, so the old key was silently ignored and the security chain ran first.A missing
Platform-TenantIdreturned 401 instead of 400. Spring Security also filters the ERROR dispatch, so thesendError(400)from the tenant filter was authorized again on/error, where there is no authentication. Fixed by making/errorpermitAll.Every cross-origin call would have failed. Since Spring 5.3 the literal
"*"origin together withallowCredentials(true)is rejected at request time. Under Spring 4.3, which the old app ran, it was permitted and reflected the origin, soaddAllowedOriginPattern("*")restores the deployed behaviour rather than widening it.The application did not start at all, and only excluding
hibernate-coremade it happen.GroupRepositoryhas@Query("select g.submittedOnDate from Group g ..."). Spring Data JPA 3.x parses every@Queryat startup and picks its parser from the classpath: with Hibernate present it uses the lenient HQL one, without it the strict EQL grammar, whereGROUPis a reserved word. Excludinghibernate-corefrom the data-jpa starter is right on its own merits — two JPA providers in one deployable jar — but it also flips that switch, so the context failed withBad EQL grammarbefore opening a single connection.Groupis now@Entity(name = "GroupEntity"), which changes the name used in JPQL and nothing else: samem_grouptable, same Java type. Note for the other repos: the trigger is any entity whose class name is a JPQL keyword, so it is worth a look wherever the same starter exclusion is applied.Also: asking for a grant type the client is not allowed to use returned 401 where the old stack returned 400, so that path now throws a non-authentication exception and comes out as 400.
One thing that looks wrong in
TokenControllerand is not: the finalcatch (Exception)answers 400, not 500, even for a server-side failure like the database being down. That is what the old code did.AuthExceptionTranslatorwasif (e instanceof InvalidGrantException) 401 else 400, with no branch for anything else, so every non-credential failure already came out as 400. The only difference is the body: the old one returned 400 empty, this one returns{"error": "invalid_request"}.Flyway 2.1.1 to 10
Flyway 2.x kept its history in
schema_version; Flyway 10 usesflyway_schema_historywith a different layout, can read the old table but cannot write to it, and dropped the self-conversion it had up to version 4. SoFlywayHistoryTableUpgrade(shared with operations-app) converts it once beforemigrate(), keeping the old table as a backup. It does nothing on a fresh database and nothing if it has already run.Verified
23 unit tests, green, up from one empty test method.
8 of them cover the two things most likely to break silently on their own: the hand-written PKCS#1 to PKCS#8 key wrapping (a sign-then-verify round trip, which every token depends on) and the token validators.
The other 15 drive
/oauth/tokenthrough MockMvc, with the database and the authentication manager stubbed but the real encoder and decoder fromResourceServerConfigreading the realjwt.pem- so the tokens under test are the tokens the service issues. They pin the parts that are easy to break and expensive to get wrong: the 400/401 split (a client that cannot authenticate gets 401, a grant type it may not use gets 400, asAuthExceptionTranslatordid), the 401 body callers parse, the claims of an access token, and the four checks the refresh grant has to make for itself - the token belongs to the client presenting it, it really is a refresh token, the user still exists, the user is still enabled.Those four were mutation-checked - each check broken on purpose to see the suite go red. Two tests did not survive it: with the client-binding check removed, and with the refresh-claim check removed, they still passed, because the request was refused a step later when the stubbed user lookup returned null. They were green for the wrong reason. Both now stub an enabled user, so the only thing that can produce the 401 is the check under test.
30/30 HTTP checks against MySQL 5.7, on three real tenant schemas:
client_idvia Basic headertoken_keyopen with no tenant header;check_tokenauthenticated 200, unauthenticated 401/api/v1ALL_FUNCTIONS403; garbage token 401; no tenant header 400;user/1200;user/999404;user/1/roles200; transactions paged and sorted 200schema_versiontable (28 rows,type='INIT',version_rankcolumn), then restarted: converted toflyway_schema_history, 28 rows intact, backup kept,repair()ran once, no migration re-applied, tokens still issued. A second restart did not convert or repair againRe-run on the final build after bug 4 above, on an empty MySQL 5.7.44 with
hibernate-coreexcluded, so these numbers describe the commit as it stands and not an earlier classpath: startup clean, Flyway applying 1 migration totenantsand 28 to each oftn01andtn02, then the token endpoint (all three grants),/api/v1users, roles and permissions,check_token,token_key, and the refusals — refresh token as bearer, cross-tenant token, cross-tenant refresh redemption, missing tenant header, grant not allowed for the client.Token claims checked by decoding a real token:
aud: [identity-provider, tn01],authorities: [ALL_FUNCTIONS],user_name,scope,jti,client_id,exp,iat, the same set the oldJwtAccessTokenConverterproduced.Known items, flagged not fixed
The JWT signing key is in the repo.
src/main/resources/jwt.pemis the RSA private key every token is signed with, and it is committed, as it was inph-ee-identity-provider. Nothing changed here and no key file was touched, but this repo is public, so anyone can mint a token withaud: [identity-provider, <tenant>]andauthorities: [ALL_FUNCTIONS]against any deployment still running the default key. Together with theclientrow thatV27__oauth_changes.sqlseeds with noclient_secret(checkClientSecretreturns early on an empty stored secret, asJdbcClientDetailsServicedid), the password grant needs onlyclient_id=clientplus user credentials. Both predate the migration and are kept as they were, but this has to be settled before the service is deployed: the key belongs in a mounted secret, not on the classpath.PemUtilsreads fromClassPathResourceonly, so that change lands there.The Flyway migrations collide with ph-ee-operations-app, and the core schema is the worse half. Both services ship migrations for the same schemas with overlapping version numbers: on the tenant schemas 27 numbers overlap with different scripts, offset by one (
V27isoauth_changeshere andadd_refund_permissionthere), and on the core schematenant_server_connectionsis V1 here and V2 there. Flyway keys a migration by version, so the second service to run replays a migration it thinks is pending. The two halves fail differently, and the difference matters: on a tenant schemaflywayTenants()catches per tenant and only logs, so that tenant comes up with an unknown migration state; on the core schemaflywayDefaultSchema()lets the exception escape, so the application does not start at all -Table 'tenant_server_connections' already exists, reproduced against a MySQL shaped like gazelle3.This predates the migration: rerunning
com.googlecode.flyway:flyway-core:2.1.1, the version from the old pom, with the old migrations against the same schema fails identically, soph-ee-identity-providerwas never deployable there either - it has simply never been pointed at those schemas. It is also not a MySQL problem: on an empty PostgreSQL 16 the same two migrations collide the same way, so moving the platform to Postgres would not settle it by itself.The second commit here adds the switch that was missing rather than renumbering anything. The tenant schemas already had one, per tenant, in the
auto_updatecolumn; the core schema had none.fineract.datasource.core.auto-updatedefaults totrue, so a deployment with a database of its own builds it exactly as before; set tofalsethe service reads a schema it does not own and writes nothing to it - verified on a gazelle-shaped MySQL, where it starts, issues tokens, serves/api/v1/users, and leaves bothschema_versiontables untouched with noflyway_schema_historycreated. For whoever writes the chart: bothFINERACT_DATASOURCE_CORE_AUTOUPDATEandFINERACT_DATASOURCE_CORE_AUTO_UPDATEbind to it as environment variables - I checked both, since getting the name wrong would leave the flag at its default and the pod would fail to start.This unblocks the deployment, it does not resolve the collision. The numbers still overlap and the ownership question is still open: one owner for the migrations of the shared schemas -
ph-ee-operations-appis the natural one, it is deployed and it already ships everything this service reads - or separate schemas, which would defeat a shared user store. Worth settling as part of the Postgres move, when the migrations have to be rewritten anyway.GET /api/v1/usersand/api/v1/user/{id}return the bcrypt password hash.AppUserhas@JsonIgnoreongetAuthorities()andgetOffice()but not ongetPassword(), so the hash of every user goes out to any caller holdingALL_FUNCTIONS. Inherited - nothing in this PR changes the serialization, and the same is true onph-ee-identity-provider- but it is one annotation and it belongs in its own commit rather than in a migration.No actuator, so no
/actuator/health. The old app had none and I did not add one, to keep baseline parity. If this is deployed on kubernetes the probes will need it.Verified locally only.
ph-ee-identity-provideris not referenced in gazelle or inph-ee-env-template, so there is no environment to verify against. Everything above is a real MySQL and a real HTTP client, but not a real deployment.@CacheableonloadUserByUsernameis keyed on the username alone, in a service where the same username exists in every tenant. Turningcaching.enabledon would letmifosontn01andmifosontn02share one entry for 10 seconds: wrong password hash, wrong authorities, across tenants. It is inert today (caching.enabled=false, andCacheConfigis@ConditionalOnExpression) and it is inherited, so it is left alone here, but it is a cross-tenant bug sitting behind a boolean. The fix is a cache key including the tenant schema.token.access.validity-secondsandtoken.refresh.validity-secondslook like runtime settings but are not. They are Flyway placeholders consumed byV27__oauth_changes.sqlwhen it seedsoauth_client_details. On a tenant whereV27has already run, changing them does nothing; the lifetimes live in the table from then on.CORS reflects any
Originwith credentials. Pre-existing and restored rather than introduced (see bug 3 above); worth an explicit origin allow-list in its own change.Dead code kept out. There are
org.apache.fineract.organisation.*classes (documents, groups, staff, offices, leftovers from the Fineract fork) that look unreferenced. Removing them is a separate commit after this merges, as agreed for notifications.joda-time stays for the
LocalDatefields of four read-only DTOs; swapping it forjava.timeis a code change, not a version change.Package names stay
org.apache.fineract.*, per the separate ticket.Two questions
pch-java, the two nats ones).Scaffold docs
The last commit applies the org-wide scaffold docs from ph-ee-start-here (CODE_OF_CONDUCT.md, contributing.md replacing CONTRIBUTING.md, a new security.md), with the seven clone/fork/remote URLs pointing at this repo, and fixes the README line that linked CONTRIBUTING.md: blob paths on GitHub are case sensitive, so after the rename that link 404s (checked, not assumed). LICENSE is left alone; its only difference from the source copy is line 360, https:// here against http:// there, so that fix belongs in ph-ee-start-here.