ADR 0028: OAuth provider availability, the unconfigured-provider state, and just-in-time name provisioning¶
- Status: Accepted (amended 2026-08-05 at the pre-PR review gate, see Decision 6)
- Date: 2026-07-31
- Deciders: jobhub-architect (story #506, ticket #507), orchestrator gate
- Affects: auth-service, api-contracts (
auth-service.yaml,github-oauth.yaml), JobHub-ui - Extends: ADR 0027 (social login via OAuth authorization-code)
- Schema impact: none (no
db/initmigration, see Decision 3)
Context¶
Social login shipped with story #459 / ADR 0027 and is broken in production in three related ways, with no signal to the user (story #506):
- Config crash.
GoogleOAuthProviderClientandGithubOAuthProviderClientinject credentials as@ConfigProperty(name = "auth.oauth.<p>.client-id", defaultValue = ""). SmallRye Config treats an empty value as absent, and an emptydefaultValuetherefore provides nothing to fall back to. Quarkus validates every@ConfigPropertyinjection point at startup, so an unsetGOOGLE_OAUTH_CLIENT_ID(the documented default in.env.exampleand both compose files) makes the whole service fail to boot withFailed to load config value of type class java.lang.String for: auth.oauth.google.client-id. "Unconfigured", the state ADR 0027 explicitly designed for (404 from/oauth/{provider}/start), is currently unreachable: it is a crash-loop instead. - No availability signal. The login screen renders both social buttons unconditionally. A deployment that configures neither provider (the default) shows two buttons that can only fail. Nothing in the contract lets the UI know.
- Just-in-time provisioning 500.
db/init/020-auth.sqldeclaresfirst_name TEXT NOT NULLandlast_name TEXT NOT NULL;CompleteOAuthLoginService.resolveUserbuildsUser.builder().firstName(identity.getFirstName()).lastName(identity.getLastName())straight from the provider profile. A real Google account returnedgiven_namebut nofamily_name, so the insert violated the not-null constraint and the callback answered 500 (story #506, comment 1). GitHub has the same gap in a different shape: its profile often carries onlyname, and sometimes onlylogin.
The reporter also observed that after the 500 they appeared to be "inside the profile" but could not log in, that GitHub reports itself "unavailable", and asked (comment 3) that the logo on the login page navigate back to the main page.
Constraints that bound the fix:
- Contract-first:
api-contracts/.../auth-service.yamlis the single source of truth and must be frozen before the backend and frontend build in parallel. AccountResponsealready declaresrequired: [id, firstName, lastName, email, emailVerified], and it is embedded in everyLoginResponse. Names cannot silently become null.- auth-service is the Clean-architecture service: no framework annotations below Layer 3, handlers depend on ports, DTOs only at boundaries.
- The database is owned by
db/init/*.sql, forward-only, Hibernatevalidatein prod.
Decision¶
1. Optional<String> for provider credentials, so "unconfigured" is a real state¶
The four credential injection points become:
@ConfigProperty(name = "auth.oauth.google.client-id")
Optional<String> clientId; // no defaultValue at all
isConfigured() stays the single source of truth for "can this deployment offer the provider",
and reads clientId.filter(v -> !v.isBlank()).isPresent() && clientSecret.filter(...).isPresent().
buildAuthorizationUrl() and exchange() are only ever reached through isConfigured(), so they
resolve the value with orElseThrow(() -> new ProviderNotConfiguredException(provider)) rather
than orElse(""): an unconfigured provider must never produce a half-formed authorization URL or
a token request with an empty client_id.
Optional<String> is chosen because it is the only shape where both "property absent" and
"property present but empty" collapse into the same non-throwing, explicitly-modelled state, and
because the repo already uses exactly this idiom (OAuthResource.adminEmailsConfig). The
application.properties entries (${GOOGLE_OAUTH_CLIENT_ID:}), .env.example and both compose
files stay as they are: with Optional, an empty expansion is now correct rather than fatal.
Startup logs one INFO line per provider stating configured / not configured (names only, never values), so a deployment can be diagnosed without guessing.
2. GET /auth/oauth/providers, unauthenticated, list-of-objects¶
Frozen in auth-service.yaml:
GET /oauth/providers (server url /auth, so the UI-facing path is /auth/oauth/providers)
operationId: listOAuthProviders
security: [] (deliberately unauthenticated: it gates the login screen)
200 -> OAuthProvidersResponse { providers: [ OAuthProviderAvailability { provider, available } ] }
500 -> ErrorResponse
- List of objects, not a map or a fixed pair of booleans. The response enumerates every
provider auth-service knows about (
OAuthProvider.values(), stable ordergoogle,github), each with its flag. This keeps "which providers exist" as data rather than schema: adding a provider later is a pure enum change plus an icon in the UI, with no property added to a model and no consumer break. It also leaves room for a futurereasonfield without reshaping. availablemeans credentials are configured, nothing else. It is not a health probe and makes no outbound call: an available provider can still be down, which surfaces as 502 on the callback. Client ids, secrets and redirect URIs are never exposed.- Unauthenticated is mandatory, not a convenience: it is called before any token exists. It returns two booleans about deployment configuration and no user data, so it leaks nothing an attacker cannot already learn by clicking the button.
- UI rule: fail open. The login and signup screens call it once on mount, hide (not
disable-with-an-error) the buttons whose provider is
available: false, and on any error or non-200 render every known button, exactly as today. A gating call must never be able to leave a user with zero ways to sign in.
Generated-interface consequence (must not be missed). The jaxrs-spec generator groups by
first path segment and puts the group's longest common prefix on the interface. Adding
/oauth/providers shortens that prefix from /oauth/{provider} to /oauth, so OauthApi now
carries @Path("/oauth") with method paths /{provider}/start, /{provider}/callback and
/providers. OAuthResource's class-level @Path is therefore changed to /oauth in this same
commit; the effective routes are byte-for-byte unchanged and OAuthStartComponentTest passes.
Ticket #510 must keep them in sync and must replace the 501 Not Implemented scaffold in
OAuthResource.listOAuthProviders().
Clean layering for the build-out:
application/port/in/ListOAuthProvidersUseCasereturningList<OAuthProviderAvailability>, a record next toLoginResult/OAuthAuthorizationResultinport/in(existing precedent).application/usecase/ListOAuthProvidersServiceiteratesOAuthProvider.values()and, for each, finds theOAuthProviderClientthatsupports()it and readsisConfigured(). It depends only on the existing out-port, and gains no new port.OAuthResourcemaps the result to the generatedOAuthProvidersResponse. No new resource class: one implementing class per generated interface is the house pattern.
3. Missing names: derive a non-null display name at provisioning (option b). No migration.¶
Option (a), relaxing first_name / last_name to nullable, is rejected. AccountResponse
declares both as required, and it rides inside every LoginResponse and GET /account. Making
the columns nullable without also removing them from required produces a contract-violating
payload; removing them from required is a breaking contract change that ripples into the UI
(initials, avatars, greetings) and every consumer, to model a case the product does not actually
want: a JobHub account with no name at all. The not-null constraint is not the bug. It caught the
bug. We satisfy it instead of deleting it.
Decision: the provider adapters report raw signals; a pure domain rule derives the name.
ExternalIdentitygains two nullable raw fields,fullNameandusername.GoogleIdentityMapperfillsfullNamefromname(already ingoogle-oauth.yaml);GithubIdentityMapperfillsfullNamefromnameandusernamefromlogin, and stops doing its own splitting. Adapters map, they do not decide.- A new pure Layer 1 domain service,
domain/service/ProviderDisplayName(zero framework annotations), applies one rule for both providers and returns a never-null pair: firstName= first non-blank of: the provider's own first-name field; the first whitespace token offullName;username; the local part of the normalized email; the literalUser.lastName= first non-blank of: the provider's own last-name field; the remainder offullNameafter its first token; otherwise"". An empty string is the honest value for a mononym; we never fabricate a surname.- Both values are trimmed, internal whitespace collapsed, and truncated to 100 characters.
CompleteOAuthLoginService.resolveUsercalls it on the just-in-time provisioning branch only. Names are provisioning-time only: the auto-link branch and every subsequent login leave the stored name untouched, so a provider can never overwrite a name the user has edited. A derived name is always correctable through the existingPATCH /account.
Because the columns keep their NOT NULL, no db/init migration is required, no migration
number is assigned to #510, and neither podman-compose.yml nor podman-compose.native.yml needs
a new init mount entry. db/init and the compose mount lists are untouched by story #506. If a
future story genuinely wants nameless accounts, that story starts by relaxing AccountResponse,
not by relaxing the column.
No partial write, no partial session (the "inside the profile" report).
- Server side:
CompleteOAuthLoginService.handleis a single@Transactional(REQUIRED) covering the user insert, theuser_identityinsert and token generation; there is noREQUIRES_NEWanywhere in auth-service. The failing insert therefore rolled the whole transaction back and noauth.userrow was committed. The id in the log line is the id of the row that was attempted. This is now a constraint, not an accident: the callback stays one transaction, and no code on that path may open a nested or independent transaction. #510 keeps a component test asserting that a failed callback leaves zero rows inauth.userandauth.user_identity. - Client side:
completeOAuthLoginwrites a token only from a 2xx body that carries one (request()throwsApiErroron any non-2xx first), andOAuthCallbackScreencallsonCompleteonly on success. No session was created. The reported symptom is explained by a pre-existing session in the same browser plus the rolled-back id in the log. The UI ticket keeps a regression test: a 500 from the callback shows the callback error screen, leaves the stored token untouched, and never enters the app shell. It must not clear an existing token either: that session belongs to a different, legitimately logged-in account.
4. GitHub "unavailable" is a separate defect, not the unconfigured path¶
The finding is explicit: this is not ProviderNotConfiguredException and decisions 1 + 2 do not
fix it. The evidence chain:
- If either GitHub credential had been empty, the pre-fix
@ConfigProperty Stringinjection would have failed auth-service's startup config validation and the service would have been crash-looping. It was serving the Google flow in the same log, so both GitHub values were present andisConfigured()returned true. - The wording the reporter saw is the UI's 502 branch (
"GitHub is unavailable right now."), not the 404 branch, whose message is"unknown or unconfigured oauth provider: github". - GitHub does not use a 4xx status for OAuth token failures. A wrong client id/secret, a stale
code, or a callback URL that does not matchOAUTH_REDIRECT_BASE_URL + /oauth/github/callbackall come back as HTTP 200 with noaccess_tokenand anerrorfield. The currentGithubOAuthProviderClienttreats that as success, sendsAuthorization: Bearer nullto/user, receives 401, and wraps it asProviderUnavailableException-> 502. A credential or callback-URL misconfiguration is thus reported to the user as a GitHub outage.
Scope for #510 (small, bounded): after the token exchange, a blank access_token throws
ProviderAuthorizationFailedException (401, "we could not sign you in") instead of continuing;
error and error_description are logged at WARN so the real cause is diagnosable. error,
error_description and error_uri are added to GithubTokenResponse in github-oauth.yaml in
this freeze. Google needs no equivalent: its token endpoint answers 4xx and is already mapped to
401. The underlying deployment cause (the GitHub OAuth App's registered callback URL) remains a
DevOps check, but after this change it fails honestly instead of blaming GitHub.
5. Login-page logo navigation (comment 3): no contract, no guard conflict¶
Confirmed safe. JobSearchScreen ("search") is not in PROTECTED_ROUTES and already takes
authed as a prop, so an anonymous user may browse it. The auth gate only intercepts
route === "login" | "signup" and protected routes, so goto("search") from the login screen
exits the auth screen and does not bounce back. The frontend ticket passes a navigation callback
into LoginScreen / SignUpScreen for the logo. It is deliberately not wired on
OAuthCallbackScreen: that screen is still sitting on the /oauth/{provider}/callback URL, whose
only correct exits are its existing "Back to sign in" action and clearOAuthCallbackUrl().
6. The "classloader eats every @JsonProperty rename" reframing is rejected (amendment, 2026-08-05)¶
Ticket #510 reported, at the P3 gate, that Decision 3's premise was wrong: that under this
module's Jackson/Quarkus classloading a generated api-contracts model can resolve through a
different classloader than the one that loaded @JsonProperty, so Jackson's annotation-identity
check silently misses the rename and EVERY snake_case provider field (given_name,
family_name, email_verified, access_token, error_description) deserializes as null.
Two pieces of production code were added on that premise:
adapter/out/client/ExternalProviderJsonSupport.snakeCase() wired into the four Google/GitHub
REST clients via @ClientObjectMapper, and adapter/in/rest/EnumJsonValueObjectMapperCustomizer,
an app-wide ObjectMapperCustomizer enabling WRITE_ENUMS_USING_TO_STRING. #510 asked that
Decision 3 be downgraded to "defensive fallback".
The reframing does not survive the evidence. Decision 3's original premise stands unchanged,
and ProviderDisplayName remains the primary fix, not a fallback.
- The production log in story #506 falsifies it directly. The failing insert was
(id, David_tests, NULL, david.tests.email@gmail.com, NULL, t, NULL, ts, ts, f)againstauth.user's physical column orderid, first_name, last_name, email, password_hash, email_verified, email_verified_at, created_at, updated_at, two_factor_enabled. So in the real running servicegiven_namedeserialized toDavid_testsandemail_verifieddeserialized totrue. Both are snake_case@JsonPropertyrenames onGoogleUserInfoResponse. Onlyfamily_namewas null, which is exactly "Google supplied no family name" for a mononym account. access_tokendeserialized too. The flow reached a DB insert carrying the account's real email, which is only obtainable from a/v1/userinfocall authorized with a non-null bearer token. Hadaccess_tokenbeen null the call would have 401'd into a 502, never a constraint violation.- Neither workaround is load-bearing in the test suite either. Removing both and running
mvn -pl auth-service teston the story branch leaves 262/262 unit and 190/190 component tests green, including all 29OAuthCallbackComponentTestcases, the 13 newOAuthCallbackNameDerivationComponentTestcases, the 6GithubTokenExchangeComponentTestcases, andOAuthProvidersComponentTestTC-506-B1, which asserts the lower-case wire valueproviders[0].provider == "google".@JsonValueand@JsonPropertyare honoured with the customizer and the@ClientObjectMappermethods absent. - The mechanism cannot arise in a deployed service. The duplicate-classloader precondition
requires
jackson-annotationsto be resolved twice. The fast-jar and native runtimes use a single runner classloader for application classes and dependencies; the multi-classloader hierarchy exists only in dev mode and under@QuarkusTest. - The fix would also have been incomplete on its own premise. Inbound enum binding
(
ConsumeVerificationRequest.ActionEnum,VerificationRequest.ActionEnum) depends on@JsonCreator, which is annotation-identity sensitive in exactly the same way and is not addressed byWRITE_ENUMS_USING_TO_STRING. Those endpoints work.
What the observation probably was. The reported "13 of #459's 29 existing
OAuthCallbackComponentTest cases" almost certainly came from the pre-existing local
full-suite fork cascade (memory: job-service-local-fullsuite-wsl-artifact), where a
@TestProfile-triggered Quarkus reboot inside a reused fork poisons every later test in that
fork. Ticket #510 fixed that properly, in the same ticket, with the 3-bucket surefire split
(commit 115ed63). That split is the accepted fix and stays.
Decisions:
EnumJsonValueObjectMapperCustomizeris rejected and must be removed. It mutates the app-wideObjectMapperused by every auth-service response and every outbound REST client, to work around a symptom that does not exist. It is a no-op on today's wire format only because every generated enum'stoString()already returns its@JsonValuewire value and no hand-written auth-service enum overridestoString()at all; the day someone adds an enum with a human-readabletoString(), its wire format changes silently and globally. Production code whose only justification is a test observation is forbidden (memory:no-production-code-for-testing).ExternalProviderJsonSupportplus the four@ClientObjectMappermethods are rejected and must be removed. Beyond being unnecessary, a blanketSNAKE_CASEnaming strategy on the provider clients is a second, competing source of truth for wire names alongside the generated@JsonPropertyannotations, and it silently masks a genuine future spec/model mismatch instead of surfacing it.- If a defence-in-depth argument is ever made for either, it needs a failing test that reproduces the mechanism, not a javadoc hedge. Neither is re-introduced without a new ADR.
- No other JobHub service is exposed to the alleged bug, because the alleged bug is not real.
For the record,
ollama.yaml(done_reason,prompt_eval_count,eval_count,total_duration,load_duration) andopenai.yaml(response_format,finish_reason,prompt_tokens,completion_tokens,total_tokens) are the only other specs with renamed properties; crawler-service consumes them with no naming-strategy workaround and works in production, which is a third independent data point.
Consequences¶
- Positive: an unconfigured deployment now boots, answers a documented 404 on
/start, and tells the UI which buttons to render. The default.env.example(no credentials at all) becomes a supported configuration instead of a crash. - Positive: the availability endpoint is additive and unauthenticated, so it costs no change to any
existing model and no downstream consumer is affected (memory:
contract-change-check-consumers). - Positive: no schema change, therefore no migration ordering risk and no compose mount drift
(memory:
compose-init-mounts-lag-migrations). - Cost: a derived name can be cosmetically wrong (an email local part as a first name). It is
user-correctable through
PATCH /account, which we judge better than either a 500 or a nameless account. - Cost: the generated
OauthApibase path changed, soOAuthResourceand the generated interface must be kept in sync. Covered by the existingOAuthStartComponentTest/OAuthCallbackComponentTest. - Known debt, deliberately not touched here:
x-implementation-statusinauth-service.yamlis stale across the file (shipped 2FA, admin and OAuth work is still markedplanned). New work in this ADR is markedplannedto match the file's current convention; a separate hygiene pass should reconcile the whole file rather than one section of it.
Build-out plan¶
Backend, ticket #510 (auth-service, no migration):
Optional<String>credentials +isConfigured()+orElseThrowguards in both provider clients; startup INFO line per provider.ListOAuthProvidersUseCaseport in +ListOAuthProvidersService+ replace the501scaffold inOAuthResource.listOAuthProviders().ExternalIdentity.fullName/.username; adapters map raw signals only;domain/service/ProviderDisplayName; call it from the JIT branch ofresolveUser.- Blank-
access_tokenguard inGithubOAuthProviderClient-> 401 + WARN log oferror/error_description. - Tests: unit for
ProviderDisplayName(Google mononym, Google no names at all, GitHub name-only, GitHub login-only, whitespace, over-length) and forisConfigured()(absent, blank, set); component forGET /oauth/providers(both configured, one configured, neither) and for the rollback invariant (failed callback leaves zero rows); WireMock for the GitHub HTTP-200-with-error token response.
Frontend ticket: call GET /auth/oauth/providers on the login and signup screens, hide
unavailable buttons, fail open on error, logo navigates to the main page, and keep the
callback-failure regression test described in Decision 3.
Alternatives considered¶
- A sentinel default (
defaultValue = "unset") rejected: a magic string that silently becomes a realclient_idin an authorization URL the moment one guard is missed.Optionalmakes the absent case unrepresentable-by-accident. @ConfigMappingforauth.oauth.*rejected for now: it is a larger refactor of four classes and their tests, still needsOptionalfor exactly the same reason, and buys no behaviour that the two-line fix does not.- A map response (
{"google": true, "github": false}) or two named booleans rejected: it generates aMap<String, Boolean>(or a model that must grow a property per provider) and turns "which providers exist" into schema. The list keeps that as data. - Making the endpoint authenticated, or folding availability into an existing endpoint rejected: it gates the pre-login screen, so it must be callable with no token, and no existing pre-auth endpoint is a natural host.
- Relaxing
first_name/last_nameto nullable (option a) rejected: see Decision 3. It breaks arequiredcontract field to model a case the product does not want. - Deriving the name in each provider adapter rejected: two adapters would drift, which is
precisely how GitHub ended up with a
""surname while Google produced a null one. One pure domain rule, unit-tested once, covers both.