Localised Client Metadata (Application & DCR) #2135
Replies: 1 comment 3 replies
-
|
The API layer design of accepting and returning For the internal implementation, we'd like to propose a different storage strategy than embedding StorageRather than storing localized variant maps directly on the application record, we store a translation key reference in the localizable field and keep the actual translated values in the i18n translations table — the same pattern already established for runtime-defined messages in #927.
The namespace
|
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Problem Statement
Client name and other human-readable metadata are only displayed in English. Client metadata should change based on the user-selected
ui_locale.Goals
client_name,tos_uri,policy_uri, andlogo_uriat /flow/meta endpoint along with falling back, base (untagged) value.ui_localeshould be returned in the /flow/meta endpoint. UI MUST reflect the preferredui_localeand change the client metadata displayed on the UI w.r.t the current selectedui_locale. Fallback to base (untagged) value if theui_localeis missing or unknown.Non-Goals
client_name,tos_uri,policy_uri, andlogo_uri.Acceptance Criteria
client_name#frand baseclient_nameclient_name#devariantPOST /registerwithclient_name#frand baseclient_namePUT /register/{client_id}to add or overwrite a tagged variantclient_name#en-US)client_name#(empty tag)400 invalid_client_metadataclient_name#en USorclient_name#en!(illegal characters)400 invalid_client_metadataclient_name#en#US(multiple#)400 invalid_client_metadata400 invalid_client_metadataclient_name#frandclient_name#FRin the same requestfr,FR,Frall resolve to the same stored variantredirect_uris#fr(tagged variant on non-localisable field)logo_uri#frwith a non-HTTPS or malformed URL400 invalid_client_metadata, same rules as baselogo_uri400 invalid_client_metadataindicating the limit/flow/meta— exact matchGET /flow/meta?type=APP&id={appId}&flowId={flowId}; flow context hasui_locale=fr;client_name#frregisteredclient_namein response resolves to thefrvariant/flow/meta— subtag fallbackui_locale=fr-CA; onlyclient_name#frregisteredclient_nameresolves to thefrvariant (language-only fallback applies)/flow/meta— base fallbackui_locale=de; noclient_name#deregisteredclient_namefalls back to base (untagged) value/flow/meta— noflowIdprovidedGET /flow/meta?type=APP&id={appId}with noflowId/flow/meta— expired or unknownflowIdflowIdprovided but flow does not exist or has expired4xx/5xx/flow/meta— invalidui_localein flow contextui_locale=!!invalid4xx/5xx/flow/meta— space-separatedui_localeui_locale=de fr(OIDC spec list)/flow/meta—ui_localein responseGET /flow/metawith a validflowIdwhose context has aui_localeui_localevalue from the flow contextui_localesentui_locale401/403#) round-trip throughencoding/jsonwithout manglingGET /register/{client_id}Edge Cases & Constraints
Input & Parsing
#with no tag —client_name#has an empty language tag; must be rejected with400 invalid_client_metadata.#in field name —client_name#en#USis ambiguous; the#separator is used only once — the tag itself may contain-for BCP 47 subtags (e.g.,client_name#en-US). Reject with400.fr,FR,Frmust resolve to the same variant); normalise to lowercase on write.client_name#en USorclient_name#en!must be rejected with400 invalid_client_metadata.client_name#frandclient_name#FRresolves to a conflict after normalisation; last-write-wins (the last occurrence in the parsed map is stored).Storage & Data Integrity
client_name) should always be provided and is used as the fallback when no registered variant matches the requestedui_locale. If the base is absent and no variant matches, the field is returned empty.client_namebut leavesclient_name#frcreates an inconsistent state; explicitly permitted but/flow/metawill only return the matching tagged variant or empty.400 invalid_client_metadata.redirect_uris#fr) is silently ignored; the field is stripped before storage.Locale Resolution
ui_locale=fr-CAis requested but onlyfris registered,fris returned as the resolved value. The resolution chain is: exact match → language-only prefix match → base value.ui_localewith multiple values — space-separated list per OIDC spec; first matching variant wins.ui_localecontaining an invalid BCP 47 tag at runtime — server degrades gracefully to the base value; no4xx/5xxreturned.Security & Misuse
sysutils.SanitizeString()pattern before storage.logo_uri#fr,tos_uri#fr,policy_uri#frmust be validated with the same HTTPS and well-formedness checks as their base fields.system/security/.Backwards Compatibility
ui_locale— receive base values exactly as before; no behaviour change.encoding/jsonis expected to round-trip keys containing#without escaping, consistent with the existingapp_jsoncolumn pattern; must be confirmed with a unit test before relying on it.Technical Notes
Changes Required
1. Client Model —
backend/internal/application/model/application.goAdd localised variant maps to
ApplicationDTOandApplicationProcessedDTO:When deserialising an inbound request, a pre-processing step must scan all JSON keys for the
#separator, extract the tag, validate it as BCP 47, normalise it to lowercase, and populate the corresponding map. This step must run before standardjson.Unmarshalso that unknown tagged keys do not surface as errors.2. DCR Model —
backend/internal/oauth/oauth2/dcr/model.goDCRRegistrationRequestandDCRRegistrationResponseuse fixed struct fields. Tagged keys (client_name#fr) cannot be captured by fixed fields.Approach: custom
UnmarshalJSON/MarshalJSON— preferred over a catch-allAdditionalFieldsmap, which would leak into the response and require a separate filter pass.Request (
UnmarshalJSON) — decode fixed fields via an alias type to avoid recursion, then scan raw keys for the#separator to populate the localised maps:Response (
MarshalJSON) — marshal the struct normally via alias, then inject tagged keys as top-level properties:The four localised maps (
LocalisedClientName,LocalisedLogoURL,LocalisedTosURI,LocalisedPolicyURI) are added to bothDCRRegistrationRequestandDCRRegistrationResponsewithjson:"-"tags so standard marshalling ignores them — only the custom methods handle them.3. BCP 47 Validation Utility —
backend/internal/system/utils/(new filelocale_util.go)A lightweight validation function:
No external library needed — a regex covering primary language + optional subtags is sufficient. Place alongside the existing
MatchRedirectURIPatterninhttp_util.goor in a newlocale_util.go.4. Application Service —
backend/internal/application/service.govalidateApplication()— add a call to validate all tagged keys inLocalisedClientName,LocalisedLogoURL,LocalisedTosURI,LocalisedPolicyURI: BCP 47 tag format, tag length cap, variant count cap (≤ 20 per field), URI validity for tagged URI fields.CreateApplication()/UpdateApplication()— normalise all tag keys to lowercase before passing to the store. On update, merge incoming tagged variants with existing ones (do not overwrite unmentioned variants).5. Storage —
backend/internal/application/store.goThe localised variant maps are included inside
app_json(the existing JSON column). No schema migration is required. TheApplicationProcessedDTOmust serialise the four*_localisedmaps intoapp_jsonand deserialise them on read.Verify the JSON column size limit is sufficient for 4 fields × 20 variants × reasonable string length; if using SQLite,
TEXTis unbounded; if Postgres,jsonbis also unbounded — no action needed.6. Authorize Endpoint —
backend/internal/oauth/oauth2/authz/ui_localearrives as a query parameter onGET /authorizealongsideclient_id,scope, etc. It is not currently extracted.service.go—HandleInitialAuthorizationRequest():ui_localefrom theOAuthParameters(addUILocale stringfield to theOAuthParametersmodel inoauth/oauth2/model/parameter.go).flow/common/constants.go:ui_localeinto theRuntimeDatamap when buildingFlowInitContext, alongside the existingRuntimeKeyRequiredLocalesentry:authRequestContext(inauth_req_store.go) alongside other OAuth parameters for consistency.7.
/flow/metaEndpoint —backend/internal/flow/flowmeta/ui_localeis not a direct query param here. It must be read from the flow'sRuntimeDatausing theflow_idthat the client already holds from the authorize response.handler.go:flowIdquery parameter; sanitise withsysutils.SanitizeString().service.go—GetFlowMetadata():flowID *stringas a new parameter.flowIDis provided, load the flow context from the flow execution store (flowexecstore) and extractRuntimeData[RuntimeKeyUILocale].ui_localeto callResolveLocalisedValuefor each ofclient_name,logo_uri,tos_uri,policy_uri.flowIDis absent or the flow context has noui_locale, fall back to base (untagged) values — no error.ui_localestring inFlowMetadataResponse.model.go— addUILocale stringtoFlowMetadataResponseandApplicationMetadata.Locale resolution helper (in
system/utils/locale_util.go):For space-separated
ui_localelists (OIDC spec), iterate in order and return the first match.Data flow summary:
8. DCR Service —
backend/internal/oauth/oauth2/dcr/service.goRegisterClient()— the customUnmarshalJSONonDCRRegistrationRequest(Section 2) handles extraction and validation of tagged fields; the service receives a fully populatedLocalisedClientNameetc. on the request struct and maps them toApplicationDTObefore calling the application service.GET /register/{client_id}— return all stored tagged variants in the response; do not perform locale resolution (raw registered data, per AC-28).Services & Layers Touched
oauth/oauth2/model/parameter.goUILocale stringfield toOAuthParametersflow/common/constants.goRuntimeKeyUILocale = "ui_locale"oauth/oauth2/authz/service.goui_localefrom authorize request; write to flowRuntimeDataandauthRequestContextapplication/model/application.gomap[string]stringlocalised variant fields toApplicationDTOoauth/oauth2/dcr/model.go#-keyed fields in DCR request/responsesystem/utils/locale_util.go(new)IsValidBCP47Tag,NormaliseBCP47Tag,ResolveLocalisedValueapplication/service.goapplication/store.goapp_jsonoauth/oauth2/dcr/service.goflow/flowmeta/handler.goflowIdquery paramflow/flowmeta/service.goflowId; readui_localefromRuntimeData; resolve and return localised variantsflow/flowmeta/model.goUILocaletoFlowMetadataResponseandApplicationMetadataKnown Gaps & Decisions Needed
#— verify early with a unit test thatencoding/jsonround-trips{"client_name#fr": "..."}without mangling; the existingapp_jsonJSON column pattern suggests this will work.ui_localeto the backend authorize request must be confirmed before frontend integration is considered complete.Dependencies
#-suffix convention.Open Questions
client_name) required when language-tagged variants are present, or can a client register with only tagged variants?ui_locale=fr-CAresolve to afrvariant iffr-CAis not registered?ui_localeis a space-separated list (as permitted by OIDC spec), is first-match-wins the correct resolution strategy?app_jsoncolumn, or a separateclient_metadatakey-value table?app_json; additive change, no schema migration requiredredirect_uris#fr) be rejected with an error or silently ignored?ui_localefrom the login gate to the backend authorization request? If not, does the frontend need to set it explicitly?tos_uriandpolicy_uritagged variants subject to the same URI allow-list or HTTPS enforcement as the base fields?GET /register/{client_id}) expose all registered tagged variants, or only the resolved value for the current request's locale?References
Beta Was this translation helpful? Give feedback.
All reactions