Skip to content

DF-1159: Show a citizen the forms they have saved - #1277

Open
alexluckett wants to merge 19 commits into
mainfrom
DF-1159-jwt-access-tokens
Open

alexluckett wants to merge 19 commits into
mainfrom
DF-1159-jwt-access-tokens

Conversation

@alexluckett

Copy link
Copy Markdown
Contributor

The homepage lists the forms that the signed-in citizen saved for this form. To get the list, the runner gets an access token that forms-submission-api accepts.

Architecture

Sign-in names forms-submission-api as the resource on the authorization request and on the token request. The provider then issues a JWT for that API. The runner keeps the token in the session and sends it to the API as a bearer token. The token identifies the citizen, so the request sends only the form ID.

A token for a resource cannot call the userinfo endpoint. The provider puts the email in the ID token, and the runner reads the email from there.

Key changes

  • The client assertion is signed with RS256, because the provider accepts only RS256. Change the keypair at the same time as the public key in forms-identity-ui (OIDC_RUNNER_JWKS).
  • OIDC_SUBMISSION_API_RESOURCE sets the resource name. The default is urn:defra:forms:forms-submission-api.
  • submissionService.js holds all calls to forms-submission-api: the saved forms list, the save-and-exit calls and generateReferenceNumber. formsService.js calls only the forms manager.
  • The table shows the reference number, an "In progress" tag, the last saved time and the expiry time. With no saved forms, the page shows a message.
  • Dates show in UK time, for example "10 September 2026 at 14:00". The API stores UTC, and the day can change with the time zone.
  • The homepage is 1200px wide. A body class widens the header, navigation, cookie banner, content and footer together. Form pages keep the default width.

Out of scope

  • The Actions column, with Continue and Delete links (DF-1159)
  • The "Expired" status tag (DF-1159) - technically it's included here but since submission-api drops records due to its TTL it means in practice it won't be used. This will be properly handled as a follow-up.

The identity provider now accepts RS256 alone, so the assertion this
service proves itself with is signed by an RSA key rather than an
elliptic curve one.

The keypair must be rotated with the provider in step: it holds the
public half as OIDC_RUNNER_JWKS, and sign-in fails while the two are
different kinds of key. Generate a matched pair with the
generate-client-keypair script in forms-identity-ui.
The sign in now names forms-submission-api as the resource it wants a
token for, on the authorization request and the token request alike.
Naming it at the token endpoint alone returns an opaque token and no
error, so both carry it.

The token is kept in the session, where it is read to call that API and
never reaches the browser.

A token bound to a resource cannot reach the userinfo endpoint, so the
provider puts the scope's claims in the ID token instead and the email
is read from there.
The homepage now lists the forms the signed-in citizen saved and has not
yet submitted, soonest to expire first, with the reference number, an in
progress tag, when it was last saved and when it expires. A citizen who
has saved nothing is told so rather than shown an empty table.

The access token in the session names the citizen to
forms-submission-api, so the request carries no identifier of its own.

Dates are formatted in the route rather than the template, so they are
covered by a test.
Both dates in the table now read as GOV.UK writes them, for example
"10 September 2026 at 14:00".

They are read in UK time. A timestamp is stored in UTC, so the day it
falls on depends on where it is read: half past eleven at night in UTC
is already the next day in British Summer Time. Naming the zone keeps
every server showing a citizen the same day, which matters most for the
date their saved form expires.
The saved forms table needs more than the default 960px, so the homepage
carries a body class that widens every container on the page: the
header, the service navigation, the cookie banner, the main content and
the footer. Widening the main content alone leaves the header and footer
at the old width, and the seam shows. The service navigation and the
cookie banner hardcode their container, so there is no parameter to pass
them.

The width is set on the homepage rather than the layout, which is shared
with every form page rendered by forms-engine-plugin, where a shorter
line length is easier to read.

The class is built with the govuk-width-container mixin, which brings
the responsive margins and safe area insets with it.
The forms service reads form metadata and definitions from the manager.
The save-and-exit calls and the saved forms list read from the submission
API, so they sit together in their own service.
The submission API sends timestamps as strings, so the helper takes a
string rather than a string or a date.
Drops the elliptic curve key case and the userinfo expectation in the
sign-out test, since the client signs with RS256 and reads the email from
the ID token. Reads the token endpoint parameters by index, as the other
mock call assertions do.
It calls the submission API, so it sits with the other submission API
calls. The forms service now reads only from the manager.
The Identity typedef gained an access token, so the session value and the
request credentials declare it as well, and the fixtures carry one.
The citizen-session scheme always puts a full identity on the credentials,
and every route that reads them requires sign in. The hapi typings now say
so, so that routes read the fields directly rather than cast each one.
The homepage table shows a red "Expired" tag for a saved form whose
expiry time has passed, and the teal "In progress" tag for the others.

The route chooses each form's status, so the choice is tested with the
route and more statuses can be added in one place. Each status is also
the translation key of its tag text, and the template maps it to the
tag class.
cy.json had two top-level "signIn" keys. JSON keeps only the last one, so
the Welsh caption, start button, preview banner and account menu fell back
to English. The table strings now sit in the first signIn.homepage block,
the same layout as en-GB.json.
formatDateTime takes the page language, and Intl supplies the month name
and the word that joins the date to the time, for example "10 Medi 2026
am 14:00". The route passes the language getFormTranslator resolves, so a
form with no Welsh translation keeps English dates.

The language is a required argument rather than a default, so that each
caller states which language it formats for.
@sonarqubecloud

Copy link
Copy Markdown

@use "govuk-frontend" as *;

// Widens every container, so the header and footer line up with the table
.app-page--wide .govuk-width-container {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This class doesn't seem to be used. It's reference in homepage.html but then not used, as far as I can tell.

* @returns {string}
*/
export function formatDateTime(timestamp, language) {
return new Intl.DateTimeFormat(language, {

@jbarnsley10 jbarnsley10 Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we need to use am/pm here instead of 24 hr to be consistent with other areas where we've used a full timestamp e.g. designer form history?

{% from "govuk/components/table/macro.njk" import govukTable %}
{% from "govuk/components/tag/macro.njk" import govukTag %}

{% set bodyClasses = "app-page--wide" %}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Doesn't seem to be used

@jbarnsley10 jbarnsley10 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks very good. Just a couple of minor comments. Approving in principal

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.

2 participants