diff --git a/hypaware-core/smoke/flows/remote_oidc_login.js b/hypaware-core/smoke/flows/remote_oidc_login.js index d2d4b3c4..78f257a4 100644 --- a/hypaware-core/smoke/flows/remote_oidc_login.js +++ b/hypaware-core/smoke/flows/remote_oidc_login.js @@ -165,7 +165,9 @@ function startStubServer() { state.validJwt = `jwt-${state.jwtSeq}` return state.validJwt } - const FUTURE = '2999-01-01T00:00:00Z' + // The server sends `expires_at` as a Unix epoch-second (the JWT `exp`), so the + // stub mirrors that wire form; the client normalizes it to ISO on the way in. + const FUTURE = Math.floor(Date.parse('2999-01-01T00:00:00Z') / 1000) const server = http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://127.0.0.1') diff --git a/llp/0059-oidc-login-client.design.md b/llp/0059-oidc-login-client.design.md index 4c3cd533..a26291b4 100644 --- a/llp/0059-oidc-login-client.design.md +++ b/llp/0059-oidc-login-client.design.md @@ -54,8 +54,10 @@ the client sent; `error` is a provider error or a resolution error (`access_deni - `{ "grant_type": "refresh_token", "refresh_token": "..." }` → `200 { access_jwt, expires_at, org }` or `401 { error: "invalid_grant" }` -Note the response field is `access_jwt` (not `access_token`) and `expires_at` is an ISO -timestamp. This is hypaware-server's own token, verified by its own resource path; there +Note the response field is `access_jwt` (not `access_token`) and `expires_at` is a Unix +epoch-second (the JWT `exp`, matching the server's bootstrap/refresh identity endpoints); +the client normalizes it to an ISO string on the way in, so the stored session stays +ISO-based. This is hypaware-server's own token, verified by its own resource path; there is no external JWKS on the client. ## Modules to add (under `src/core/remote/`) diff --git a/src/core/remote/identity_client.js b/src/core/remote/identity_client.js index cf79ed14..a3933595 100644 --- a/src/core/remote/identity_client.js +++ b/src/core/remote/identity_client.js @@ -9,8 +9,11 @@ import { Attr, getLogger } from '../observability/index.js' * speaks the two token grants the server exposes (LLP 0059 §the-server- * contract): `authorization_code` (browser login) and `refresh_token` (silent * refresh). The response field is `access_jwt` (not `access_token`) and - * `expires_at` is an ISO timestamp; the JWT is hypaware-server's own - * credential, so there is no external JWKS trust on the client. + * `expires_at` is a Unix epoch-second (the JWT `exp`, matching the server's + * bootstrap/refresh identity endpoints); this client normalizes it to an ISO + * string internally, so the stored session and its refresh-decision stay + * ISO-based. The JWT is hypaware-server's own credential, so there is no + * external JWKS trust on the client. * * @import { OidcSession, RefreshedAccess } from '../../../src/core/remote/types.js' */ @@ -88,7 +91,7 @@ export async function exchangeCode({ identityBase, code, codeVerifier, fetchImpl return { refreshToken: str(json.refresh_token, 'refresh_token'), accessJwt: str(json.access_jwt, 'access_jwt'), - expiresAt: isoTimestamp(json.expires_at, 'expires_at'), + expiresAt: expiryTimestamp(json.expires_at, 'expires_at'), org: str(json.org, 'org'), } } @@ -105,7 +108,7 @@ export async function refreshSession({ identityBase, refreshToken, fetchImpl }) const json = await postToken({ identityBase, body, fetchImpl, operation: 'remote.refresh' }) return { accessJwt: str(json.access_jwt, 'access_jwt'), - expiresAt: isoTimestamp(json.expires_at, 'expires_at'), + expiresAt: expiryTimestamp(json.expires_at, 'expires_at'), // The refresh grant only has to re-mint the access JWT; `org` is fixed for // the life of the refresh token. Treat it as optional here and let the // caller keep the org it already stored, so a server that omits it on @@ -225,15 +228,24 @@ function str(v, field) { } /** - * A non-empty string that also parses as a date. The stdio proxy refreshes - * whenever the stored expiry is unparseable, so accepting a non-date - * `expires_at` (e.g. epoch-seconds-as-string) would make every forwarded - * message a fresh refresh that re-stores the same bad value and never - * self-corrects. Fail the refresh loudly at parse time instead. + * Normalize the server's `expires_at` to an ISO string. The wire value is a + * Unix epoch-second (the JWT `exp`); we convert it to ISO so the rest of the + * client (storage, `isFresh`) keeps its ISO-based, millisecond comparison. + * An ISO string is also accepted so older mocks/servers still parse. The stdio + * proxy refreshes whenever the stored expiry is unparseable, so a value that is + * neither a finite epoch-second nor a parseable date (e.g. epoch-seconds-as-a- + * string) would make every forwarded message a fresh refresh that re-stores the + * same bad value and never self-corrects. Fail loudly at parse time instead. * * @param {unknown} v @param {string} field @returns {string} */ -function isoTimestamp(v, field) { +function expiryTimestamp(v, field) { + if (typeof v === 'number') { + if (!Number.isFinite(v) || v <= 0) { + throw new Error(`identity response field '${field}' is not a valid timestamp`) + } + return new Date(v * 1000).toISOString() + } const s = str(v, field) if (Number.isNaN(Date.parse(s))) { throw new Error(`identity response field '${field}' is not a valid timestamp`) diff --git a/test/core/remote-identity-client.test.js b/test/core/remote-identity-client.test.js index 3b6d8d83..f0b2e2ac 100644 --- a/test/core/remote-identity-client.test.js +++ b/test/core/remote-identity-client.test.js @@ -44,6 +44,29 @@ test('exchangeCode posts the authorization_code grant and maps the response', as assert.deepEqual(JSON.parse(calls[0].init.body), { grant_type: 'authorization_code', code: 'auth-code', code_verifier: 'verifier-1' }) }) +test('exchangeCode accepts the server epoch-second expires_at and normalizes it to ISO', async () => { + // The server sends `expires_at` as a Unix epoch-second (the JWT `exp`); the + // client stores an ISO string, so it must convert rather than reject. + const epoch = 1814400000 + const { fetchImpl } = stubFetch({ + body: { session_id: 'sess-1', refresh_token: 'rt-1', access_jwt: 'jwt-1', expires_at: epoch, org: 'acme' }, + }) + const session = await exchangeCode({ + identityBase: 'https://hyp.internal/v1/identity', + code: 'auth-code', + codeVerifier: 'verifier-1', + fetchImpl, + }) + assert.equal(session.expiresAt, new Date(epoch * 1000).toISOString()) +}) + +test('refreshSession accepts the server epoch-second expires_at and normalizes it to ISO', async () => { + const epoch = 1814400000 + const { fetchImpl } = stubFetch({ body: { access_jwt: 'jwt-2', expires_at: epoch, org: 'acme' } }) + const refreshed = await refreshSession({ identityBase: 'https://hyp.internal/v1/identity', refreshToken: 'rt-1', fetchImpl }) + assert.equal(refreshed.expiresAt, new Date(epoch * 1000).toISOString()) +}) + test('refreshSession posts the refresh_token grant and maps the response', async () => { const { fetchImpl, calls } = stubFetch({ body: { access_jwt: 'jwt-2', expires_at: '2026-06-29T13:00:00Z', org: 'acme' }, @@ -152,7 +175,10 @@ test('a 2xx with an empty body fails as transient, not a misleading missing-fiel ) }) -test('a non-date expires_at is rejected at refresh time, not stored to loop forever', async () => { +test('a non-date expires_at string is rejected at refresh time, not stored to loop forever', async () => { + // A numeric epoch as a *string* is still rejected (only a JSON number is the + // epoch-second wire form); this prevents storing an unparseable value that + // would make every forwarded message refresh forever. const { fetchImpl } = stubFetch({ body: { access_jwt: 'jwt', expires_at: '1719600000', org: 'acme' } }) await assert.rejects( () => refreshSession({ identityBase: 'https://hyp.internal/v1/identity', refreshToken: 'rt', fetchImpl }),