Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion hypaware-core/smoke/flows/remote_oidc_login.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
6 changes: 4 additions & 2 deletions llp/0059-oidc-login-client.design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)
Expand Down
32 changes: 22 additions & 10 deletions src/core/remote/identity_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
*/
Expand Down Expand Up @@ -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'),
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Low severity, non-blocking. The number branch guards Number.isFinite(v) && v > 0 but not the magnitude, so a large-but-finite positive value (a server mistakenly sending exp in ms or µs) slips through. Two out-of-contract outcomes:

  • v > ~8.64e12 (e.g. microsecond exp): new Date(v * 1000).toISOString() throws an uncontrolled RangeError: Invalid time value, defeating this function's stated purpose of failing loudly with a descriptive error — it escapes as an unclassified error on the refresh hot path.
  • v ≈ 1.7e12 (millisecond exp): produces a valid year-56461 date, so isFresh treats the token as fresh forever and it never refreshes — silently.

Both require an out-of-spec server, so this is not blocking. If you want to harden it, validate the constructed date and/or bound the epoch to a sane range, e.g.:

if (typeof v === 'number') {
  const ms = v * 1000
  const d = new Date(ms)
  if (!Number.isFinite(v) || v <= 0 || Number.isNaN(d.getTime())) {
    throw new Error(`identity response field '${field}' is not a valid timestamp`)
  }
  return d.toISOString()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

meh

}
const s = str(v, field)
if (Number.isNaN(Date.parse(s))) {
throw new Error(`identity response field '${field}' is not a valid timestamp`)
Expand Down
28 changes: 27 additions & 1 deletion test/core/remote-identity-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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 }),
Expand Down