From 161538e2549df10f747b9e7fb901f34ead94f444 Mon Sep 17 00:00:00 2001 From: Aryan Jassal Date: Wed, 9 Jul 2025 11:55:20 +1000 Subject: [PATCH 1/3] wip: working on organising tokens --- src/tokens/payloads/index.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/tokens/payloads/index.ts diff --git a/src/tokens/payloads/index.ts b/src/tokens/payloads/index.ts new file mode 100644 index 0000000000..e69de29bb2 From b7b3bab746592fcb3e2fc6283f8116f880f44063 Mon Sep 17 00:00:00 2001 From: Aryan Jassal Date: Thu, 10 Jul 2025 12:38:31 +1000 Subject: [PATCH 2/3] feat: consolidated token logic and added compact tokens --- src/tokens/Token.ts | 31 +++++++++++++ src/tokens/payloads/authSignedIdentity.ts | 55 +++++++++++++++++++++++ src/tokens/payloads/index.ts | 1 + src/tokens/payloads/sessionToken.ts | 40 +++++++++++++++++ src/tokens/types.ts | 3 ++ src/tokens/utils.ts | 21 +++++++++ 6 files changed, 151 insertions(+) create mode 100644 src/tokens/payloads/authSignedIdentity.ts create mode 100644 src/tokens/payloads/sessionToken.ts diff --git a/src/tokens/Token.ts b/src/tokens/Token.ts index f11ad1e1d3..ba4c673589 100644 --- a/src/tokens/Token.ts +++ b/src/tokens/Token.ts @@ -79,6 +79,23 @@ class Token

{ ); } + public static fromCompact

( + signedTokenEncoded: string, + ): Token

{ + tokensUtils.assertCompactToken(signedTokenEncoded); + const [header, payload, signature] = signedTokenEncoded.split('.'); + const tokenEncoded = { + payload: payload, + signatures: [ + { + protected: header, + signature: signature, + }, + ], + } as SignedTokenEncoded; + return this.fromEncoded(tokenEncoded); + } + public constructor( payload: P, payloadEncoded: TokenPayloadEncoded, @@ -257,6 +274,20 @@ class Token

{ public toJSON() { return this.toEncoded(); } + + /** + * The compact, xxxx.yyyy.zzzz representation of this `Token` is `string`. The + * token must have exactly one signature, otherwise it cannot be converted to + * a compact token. This function will return undefined in that case. + */ + public toCompact(): string | undefined { + if (this.signatures.length !== 1) { + return; + } + const { payload, signatures } = this.toEncoded(); + const { protected: header, signature } = signatures[0]; + return `${header}.${payload}.${signature}`; + } } export default Token; diff --git a/src/tokens/payloads/authSignedIdentity.ts b/src/tokens/payloads/authSignedIdentity.ts new file mode 100644 index 0000000000..c29034ab8b --- /dev/null +++ b/src/tokens/payloads/authSignedIdentity.ts @@ -0,0 +1,55 @@ +import type { SignedToken, TokenPayload } from '../types.js'; +import type { NodeIdEncoded } from '../../ids/types.js'; +import * as tokensUtils from '../utils.js'; +import * as ids from '../../ids/index.js'; +import * as validationErrors from '../../validation/errors.js'; +import * as utils from '../../utils/index.js'; + +interface AuthSignedIdentity extends TokenPayload { + typ: 'AuthSignedIdentity'; + iss: NodeIdEncoded; + exp: number; + jti: string; +} + +function assertAuthSignedIdentity( + authSignedIdentity: unknown, +): asserts authSignedIdentity is AuthSignedIdentity { + if (!utils.isObject(authSignedIdentity)) { + throw new validationErrors.ErrorParse('must be POJO'); + } + if (authSignedIdentity['typ'] !== 'AuthSignedIdentity') { + throw new validationErrors.ErrorParse( + '`typ` property must be `AuthSignedToken`', + ); + } + if ( + authSignedIdentity['iss'] == null || + ids.decodeNodeId(authSignedIdentity['iss'] == null) + ) { + throw new validationErrors.ErrorParse( + '`iss` property must be an encoded node ID', + ); + } + if (typeof authSignedIdentity['exp'] !== 'number') { + throw new validationErrors.ErrorParse('`exp` property must be a number'); + } + if (typeof authSignedIdentity['jti'] !== 'string') { + throw new validationErrors.ErrorParse('`jti` property must be a string'); + } +} + +function parseAuthSignedIdentity( + authIdentityEncoded: unknown, +): SignedToken { + const encodedToken = + tokensUtils.parseSignedToken(authIdentityEncoded); + const authIdentity = + tokensUtils.parseTokenPayload(encodedToken); + assertAuthSignedIdentity(authIdentity); + return encodedToken; +} + +export { assertAuthSignedIdentity, parseAuthSignedIdentity }; + +export type { AuthSignedIdentity }; diff --git a/src/tokens/payloads/index.ts b/src/tokens/payloads/index.ts index e69de29bb2..1bcf45e959 100644 --- a/src/tokens/payloads/index.ts +++ b/src/tokens/payloads/index.ts @@ -0,0 +1 @@ +export * from './authSignedIdentity.js'; \ No newline at end of file diff --git a/src/tokens/payloads/sessionToken.ts b/src/tokens/payloads/sessionToken.ts new file mode 100644 index 0000000000..152df20d10 --- /dev/null +++ b/src/tokens/payloads/sessionToken.ts @@ -0,0 +1,40 @@ +import type { SignedToken, TokenPayload } from '../types.js'; +import * as tokensUtils from '../utils.js'; +import * as validationErrors from '../../validation/errors.js'; +import * as utils from '../../utils/index.js'; + +interface SessionToken extends TokenPayload { + iat: number; +} + +function assertSessionToken( + sessionToken: unknown, +): asserts sessionToken is SessionToken { + if (!utils.isObject(sessionToken)) { + throw new validationErrors.ErrorParse('must be POJO'); + } + if (sessionToken['iat'] !== 'number') { + throw new validationErrors.ErrorParse('`iat` property must be a number'); + } +} + +function parseSessionToken(sessionToken: unknown): SignedToken { + if (typeof sessionToken !== 'string') { + throw new validationErrors.ErrorParse('sessionToken must be a string'); + } + let parsedToken: unknown; + try { + parsedToken = JSON.parse(sessionToken); + } catch { + throw new validationErrors.ErrorParse('sessionToken must be valid JSON'); + } + const encodedToken = tokensUtils.parseSignedToken(parsedToken); + const sessionPayload = + tokensUtils.parseTokenPayload(encodedToken); + assertSessionToken(sessionPayload); + return encodedToken; +} + +export { assertSessionToken, parseSessionToken }; + +export type { SessionToken }; diff --git a/src/tokens/types.ts b/src/tokens/types.ts index 75452a3b8f..b6d91b8c45 100644 --- a/src/tokens/types.ts +++ b/src/tokens/types.ts @@ -118,6 +118,8 @@ type SignedTokenEncoded = { signatures: Array; }; +type CompactToken = Opaque<'CompactToken', string>; + export type { TokenPayload, TokenPayloadEncoded, @@ -132,4 +134,5 @@ export type { SignedToken, SignedTokenJSON, SignedTokenEncoded, + CompactToken, }; diff --git a/src/tokens/utils.ts b/src/tokens/utils.ts index 30308dd73b..77a5d8c56c 100644 --- a/src/tokens/utils.ts +++ b/src/tokens/utils.ts @@ -9,6 +9,7 @@ import type { SignedToken, SignedTokenEncoded, TokenHeaderSignatureEncoded, + CompactToken, } from './types.js'; import { Buffer } from 'buffer'; import canonicalize from 'canonicalize'; @@ -17,6 +18,9 @@ import * as validationErrors from '../validation/errors.js'; import * as keysUtils from '../keys/utils/index.js'; import * as utils from '../utils/index.js'; +const compactTokenAssertRegex = + /^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/; + function generateTokenPayload(payload: TokenPayload): TokenPayloadEncoded { // @ts-ignore: canonicalize exports is function improperly for ESM const payloadJSON = canonicalize(payload)!; @@ -250,6 +254,22 @@ function parseSignedToken

( }; } +/** + * Asserts a value is a valid compact token + */ +function assertCompactToken( + compactToken: unknown, +): asserts compactToken is CompactToken { + if (typeof compactToken !== 'string') { + throw new validationErrors.ErrorParse('token must be a string'); + } + if (!compactTokenAssertRegex.test(compactToken)) { + throw new validationErrors.ErrorParse( + 'Input is not a compact JWT (format: xxxx.yyyy.zzzz, base64url-encoded)', + ); + } +} + export { generateTokenPayload, generateTokenProtectedHeader, @@ -261,4 +281,5 @@ export { parseTokenSignature, parseTokenHeaderSignature, parseSignedToken, + assertCompactToken, }; From 9a56605a3180ca771157518ce5c1a9dc6da81bd4 Mon Sep 17 00:00:00 2001 From: Aryan Jassal Date: Thu, 10 Jul 2025 16:02:59 +1000 Subject: [PATCH 3/3] chore: rolled back changes for session tokens --- src/client/handlers/AuthIdentityToken.ts | 4 +- src/client/types.ts | 7 ---- ...SignedIdentity.ts => authIdentityToken.ts} | 30 ++++++-------- src/tokens/payloads/index.ts | 2 +- src/tokens/payloads/sessionToken.ts | 40 ------------------- 5 files changed, 15 insertions(+), 68 deletions(-) rename src/tokens/payloads/{authSignedIdentity.ts => authIdentityToken.ts} (55%) delete mode 100644 src/tokens/payloads/sessionToken.ts diff --git a/src/client/handlers/AuthIdentityToken.ts b/src/client/handlers/AuthIdentityToken.ts index 9c81a20fc5..ff11b7e75c 100644 --- a/src/client/handlers/AuthIdentityToken.ts +++ b/src/client/handlers/AuthIdentityToken.ts @@ -1,9 +1,9 @@ import type { ClientRPCRequestParams, ClientRPCResponseResult, - IdentityResponseData, TokenIdentityResponse, } from '../types.js'; +import type { AuthIdentityToken as AuthIdentityJWT } from '../../tokens/payloads/authIdentityToken.js'; import type KeyRing from '../../keys/KeyRing.js'; import { IdSortable } from '@matrixai/id'; import { UnaryHandler } from '@matrixai/rpc'; @@ -25,7 +25,7 @@ class AuthIdentityToken extends UnaryHandler< if (jti == null) { throw new clientErrors.ErrorClientAuthenticationInvalidJTI(); } - const outgoingToken = Token.fromPayload({ + const outgoingToken = Token.fromPayload({ jti: jti.toMultibase('base64'), exp: Math.floor(Date.now() / 1000) + 60, // 60 seconds after issuing iss: nodesUtils.encodeNodeId(keyRing.getNodeId()), diff --git a/src/client/types.ts b/src/client/types.ts index 5b6f30837e..f81b3dea10 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -108,12 +108,6 @@ type TokenMessage = { token: ProviderToken; }; -type IdentityResponseData = TokenPayload & { - jti: string; - exp: number; - iss: NodeIdEncoded; -}; - type TokenIdentityResponse = SignedTokenEncoded; // Nodes messages @@ -421,7 +415,6 @@ export type { ClaimIdMessage, ClaimNodeMessage, TokenMessage, - IdentityResponseData, TokenIdentityResponse, NodeIdMessage, AddressMessage, diff --git a/src/tokens/payloads/authSignedIdentity.ts b/src/tokens/payloads/authIdentityToken.ts similarity index 55% rename from src/tokens/payloads/authSignedIdentity.ts rename to src/tokens/payloads/authIdentityToken.ts index c29034ab8b..1f1a2761bf 100644 --- a/src/tokens/payloads/authSignedIdentity.ts +++ b/src/tokens/payloads/authIdentityToken.ts @@ -5,51 +5,45 @@ import * as ids from '../../ids/index.js'; import * as validationErrors from '../../validation/errors.js'; import * as utils from '../../utils/index.js'; -interface AuthSignedIdentity extends TokenPayload { - typ: 'AuthSignedIdentity'; +interface AuthIdentityToken extends TokenPayload { iss: NodeIdEncoded; exp: number; jti: string; } function assertAuthSignedIdentity( - authSignedIdentity: unknown, -): asserts authSignedIdentity is AuthSignedIdentity { - if (!utils.isObject(authSignedIdentity)) { + authIdentityToken: unknown, +): asserts authIdentityToken is AuthIdentityToken { + if (!utils.isObject(authIdentityToken)) { throw new validationErrors.ErrorParse('must be POJO'); } - if (authSignedIdentity['typ'] !== 'AuthSignedIdentity') { - throw new validationErrors.ErrorParse( - '`typ` property must be `AuthSignedToken`', - ); - } if ( - authSignedIdentity['iss'] == null || - ids.decodeNodeId(authSignedIdentity['iss'] == null) + authIdentityToken['iss'] == null || + ids.decodeNodeId(authIdentityToken['iss'] == null) ) { throw new validationErrors.ErrorParse( '`iss` property must be an encoded node ID', ); } - if (typeof authSignedIdentity['exp'] !== 'number') { + if (typeof authIdentityToken['exp'] !== 'number') { throw new validationErrors.ErrorParse('`exp` property must be a number'); } - if (typeof authSignedIdentity['jti'] !== 'string') { + if (typeof authIdentityToken['jti'] !== 'string') { throw new validationErrors.ErrorParse('`jti` property must be a string'); } } function parseAuthSignedIdentity( authIdentityEncoded: unknown, -): SignedToken { +): SignedToken { const encodedToken = - tokensUtils.parseSignedToken(authIdentityEncoded); + tokensUtils.parseSignedToken(authIdentityEncoded); const authIdentity = - tokensUtils.parseTokenPayload(encodedToken); + tokensUtils.parseTokenPayload(encodedToken); assertAuthSignedIdentity(authIdentity); return encodedToken; } export { assertAuthSignedIdentity, parseAuthSignedIdentity }; -export type { AuthSignedIdentity }; +export type { AuthIdentityToken }; diff --git a/src/tokens/payloads/index.ts b/src/tokens/payloads/index.ts index 1bcf45e959..165a13c556 100644 --- a/src/tokens/payloads/index.ts +++ b/src/tokens/payloads/index.ts @@ -1 +1 @@ -export * from './authSignedIdentity.js'; \ No newline at end of file +export * from './authIdentityToken.js'; \ No newline at end of file diff --git a/src/tokens/payloads/sessionToken.ts b/src/tokens/payloads/sessionToken.ts deleted file mode 100644 index 152df20d10..0000000000 --- a/src/tokens/payloads/sessionToken.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { SignedToken, TokenPayload } from '../types.js'; -import * as tokensUtils from '../utils.js'; -import * as validationErrors from '../../validation/errors.js'; -import * as utils from '../../utils/index.js'; - -interface SessionToken extends TokenPayload { - iat: number; -} - -function assertSessionToken( - sessionToken: unknown, -): asserts sessionToken is SessionToken { - if (!utils.isObject(sessionToken)) { - throw new validationErrors.ErrorParse('must be POJO'); - } - if (sessionToken['iat'] !== 'number') { - throw new validationErrors.ErrorParse('`iat` property must be a number'); - } -} - -function parseSessionToken(sessionToken: unknown): SignedToken { - if (typeof sessionToken !== 'string') { - throw new validationErrors.ErrorParse('sessionToken must be a string'); - } - let parsedToken: unknown; - try { - parsedToken = JSON.parse(sessionToken); - } catch { - throw new validationErrors.ErrorParse('sessionToken must be valid JSON'); - } - const encodedToken = tokensUtils.parseSignedToken(parsedToken); - const sessionPayload = - tokensUtils.parseTokenPayload(encodedToken); - assertSessionToken(sessionPayload); - return encodedToken; -} - -export { assertSessionToken, parseSessionToken }; - -export type { SessionToken };