Skip to content

Commit 6c4361c

Browse files
committed
Add token validation
1 parent 6bf277d commit 6c4361c

8 files changed

Lines changed: 398 additions & 45 deletions

File tree

packages/oauth/client/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ const storedToken = await client.getSession("user-session");
4343
const { access_token: refreshedAccessToken } = await client.refreshSession(
4444
"user-session"
4545
);
46+
47+
if (storedToken) {
48+
const decoded = client.parseAccessToken(storedToken.access_token);
49+
const validated = await client.validateAccessToken(storedToken.access_token);
50+
}
4651
```
4752

4853
Pass any {@link SimpleStore} implementation as the state and session store to

packages/oauth/client/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@
2222
},
2323
"dependencies": {
2424
"@iracing-data/oauth-schema": "workspace:*",
25-
"jwt-decode": "^4.0.0",
25+
"jose": "^6.2.2",
2626
"oauth4webapi": "^3.8.3",
2727
"zod": "^4.1.13"
2828
}
29-
}
29+
}

packages/oauth/client/src/client.ts

Lines changed: 73 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ import {
1313
SessionStore,
1414
StateStore,
1515
} from "./schema";
16-
import { isAccessTokenExpired, maskSecret } from "./utils";
16+
import {
17+
AccessTokenValidationOptions,
18+
decodeAccessToken,
19+
isAccessTokenExpired,
20+
isRefreshTokenExpired,
21+
maskSecret,
22+
validateAccessToken as validateDecodedAccessToken,
23+
} from "./utils";
1724

1825
export type OAuthClientOptions = {
1926
// Config
@@ -63,8 +70,8 @@ export class OAuthClient {
6370
? oauth.ClientSecretPost(
6471
maskSecret(
6572
this.clientMetadata.clientSecret,
66-
this.clientMetadata.clientId
67-
)
73+
this.clientMetadata.clientId,
74+
),
6875
)
6976
: oauth.None();
7077
}
@@ -76,7 +83,7 @@ export class OAuthClient {
7683
async authorize() {
7784
if (!this.clientMetadata.redirectUri) {
7885
throw new Error(
79-
"Client is not configured for the authorization code flow; missing `redirectUri`."
86+
"Client is not configured for the authorization code flow; missing `redirectUri`.",
8087
);
8188
}
8289

@@ -95,17 +102,17 @@ export class OAuthClient {
95102
authorizationUrl.searchParams.set("response_type", "code");
96103
authorizationUrl.searchParams.set(
97104
"client_id",
98-
this.clientMetadata.clientId
105+
this.clientMetadata.clientId,
99106
);
100107
authorizationUrl.searchParams.set(
101108
"redirect_uri",
102-
this.clientMetadata.redirectUri
109+
this.clientMetadata.redirectUri,
103110
);
104111

105112
if (this.clientMetadata.scopes) {
106113
authorizationUrl.searchParams.set(
107114
"scope",
108-
this.clientMetadata.scopes.join(" ")
115+
this.clientMetadata.scopes.join(" "),
109116
);
110117
}
111118

@@ -126,19 +133,19 @@ export class OAuthClient {
126133

127134
if (!username) {
128135
throw new Error(
129-
"No username provided for password limited authentication flow."
136+
"No username provided for password limited authentication flow.",
130137
);
131138
}
132139

133140
if (!password) {
134141
throw new Error(
135-
"No password provided for password limited authentication flow."
142+
"No password provided for password limited authentication flow.",
136143
);
137144
}
138145

139146
if (!clientSecret) {
140147
throw new Error(
141-
"Client secret not provided; password limited authorization is not allowed."
148+
"Client secret not provided; password limited authorization is not allowed.",
142149
);
143150
}
144151

@@ -170,7 +177,7 @@ export class OAuthClient {
170177
const result = await oauth.processAuthorizationCodeResponse(
171178
this.authorizationServer,
172179
this.authorizationClient,
173-
response
180+
response,
174181
);
175182

176183
const token = await IRacingOAuthTokenResponseSchema.parseAsync(result);
@@ -191,7 +198,7 @@ export class OAuthClient {
191198
async callback(params: URLSearchParams, sessionId?: string) {
192199
if (!this.clientMetadata.redirectUri) {
193200
throw new Error(
194-
"Client is not configured for the authorization code flow; missing `redirectUri`."
201+
"Client is not configured for the authorization code flow; missing `redirectUri`.",
195202
);
196203
}
197204

@@ -209,15 +216,15 @@ export class OAuthClient {
209216
} else {
210217
throw new OAuthCallbackError(
211218
params,
212-
`Unknown authorization session "${stateParam}"`
219+
`Unknown authorization session "${stateParam}"`,
213220
);
214221
}
215222

216223
if (!codeParam) {
217224
throw new OAuthCallbackError(
218225
params,
219226
'Missing "code" query parameter.',
220-
stateData.appState
227+
stateData.appState,
221228
);
222229
}
223230

@@ -227,15 +234,15 @@ export class OAuthClient {
227234
this.authorizationServer,
228235
this.authorizationClient,
229236
params,
230-
stateParam
237+
stateParam,
231238
);
232239
} catch (error) {
233240
if (error instanceof oauth.AuthorizationResponseError) {
234241
throw new OAuthCallbackError(
235242
params,
236243
"OAuth Provider returned an error",
237244
stateParam,
238-
error.cause
245+
error.cause,
239246
);
240247
}
241248

@@ -248,13 +255,13 @@ export class OAuthClient {
248255
this.clientAuthorization,
249256
codeGrantParams,
250257
this.clientMetadata.redirectUri,
251-
stateData.verifier!
258+
stateData.verifier!,
252259
);
253260

254261
const result = await oauth.processAuthorizationCodeResponse(
255262
this.authorizationServer,
256263
this.authorizationClient,
257-
response
264+
response,
258265
);
259266

260267
const token = await IRacingOAuthTokenResponseSchema.parseAsync(result);
@@ -266,7 +273,7 @@ export class OAuthClient {
266273
const profileResponse = await oauth.protectedResourceRequest(
267274
token.access_token,
268275
"GET",
269-
new URL("/iracing/profile", this.clientMetadata.issuer)
276+
new URL("/iracing/profile", this.clientMetadata.issuer),
270277
);
271278

272279
const profileData =
@@ -288,14 +295,14 @@ export class OAuthClient {
288295
this.authorizationServer,
289296
this.authorizationClient,
290297
this.clientAuthorization,
291-
token
298+
token,
292299
);
293300

294301
try {
295302
const result = await oauth.processRefreshTokenResponse(
296303
this.authorizationServer,
297304
this.authorizationClient,
298-
response
305+
response,
299306
);
300307

301308
return await IRacingOAuthTokenResponseSchema.parseAsync(result);
@@ -326,7 +333,7 @@ export class OAuthClient {
326333
path: string,
327334
headers?: Headers,
328335
body?: oauth.ProtectedResourceRequestBody,
329-
options?: oauth.ProtectedResourceRequestOptions
336+
options?: oauth.ProtectedResourceRequestOptions,
330337
) {
331338
const session = await this.restoreSession(sessionId);
332339
if (session) {
@@ -336,11 +343,11 @@ export class OAuthClient {
336343
path,
337344
headers,
338345
body,
339-
options
346+
options,
340347
);
341348
} else {
342349
throw new Error(
343-
`Could not find session matching ${sessionId}. Did you forget to authenticate?`
350+
`Could not find session matching ${sessionId}. Did you forget to authenticate?`,
344351
);
345352
}
346353
}
@@ -365,15 +372,15 @@ export class OAuthClient {
365372
path: string,
366373
headers?: Headers,
367374
body?: oauth.ProtectedResourceRequestBody,
368-
options?: oauth.ProtectedResourceRequestOptions
375+
options?: oauth.ProtectedResourceRequestOptions,
369376
) {
370377
return await oauth.protectedResourceRequest(
371378
session.access_token,
372379
method,
373380
new URL(path, "https://members-ng.iracing.com"),
374381
headers,
375382
body,
376-
options
383+
options,
377384
);
378385
}
379386

@@ -398,7 +405,7 @@ export class OAuthClient {
398405

399406
private async storeSession(
400407
sessionId: string,
401-
session: IRacingOAuthTokenResponse
408+
session: IRacingOAuthTokenResponse,
402409
) {
403410
await this.sessionStore.set(sessionId, session);
404411
}
@@ -413,7 +420,7 @@ export class OAuthClient {
413420
undefined,
414421
{
415422
sessionId,
416-
}
423+
},
417424
);
418425
}
419426

@@ -424,7 +431,15 @@ export class OAuthClient {
424431
"MISSING_REFRESH_TOKEN",
425432
{
426433
sessionId,
427-
}
434+
},
435+
);
436+
}
437+
438+
if (isRefreshTokenExpired(session.refresh_token)) {
439+
throw new OAuthRefreshError(
440+
"Refresh token cannot be refreshed",
441+
"Token expired",
442+
"REFRESH_TOKEN_EXPIRED",
428443
);
429444
}
430445

@@ -437,6 +452,35 @@ export class OAuthClient {
437452

438453
return refreshed;
439454
}
455+
456+
/**
457+
* Decodes an access token into its header and payload structure.
458+
*
459+
* This validates the token shape, but does not verify the token signature.
460+
*/
461+
parseAccessToken(accessToken: string) {
462+
return decodeAccessToken(accessToken);
463+
}
464+
465+
/**
466+
* Verifies an access token signature and then performs structural and claims validation.
467+
*
468+
* This checks signature validity, expiration, issuer, audience, scope, and claim consistency.
469+
*/
470+
async validateAccessToken(
471+
accessToken: string,
472+
options: Omit<
473+
AccessTokenValidationOptions,
474+
"issuer" | "clientId" | "requiredScopes"
475+
> = {},
476+
) {
477+
return await validateDecodedAccessToken(accessToken, {
478+
...options,
479+
issuer: this.clientMetadata.issuer,
480+
clientId: this.clientMetadata.clientId,
481+
requiredScopes: this.clientMetadata.scopes,
482+
});
483+
}
440484
}
441485

442486
export type { IRacingOAuthTokenResponse };

packages/oauth/client/src/schema/oauth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export const IRacingOAuthClientMetadataSchema = z
166166
{
167167
error:
168168
"Client must provide `authorizationUrl` and `tokenUrl` or `issuer`.",
169-
}
169+
},
170170
)
171171
.meta({
172172
title: "Client Metadata",

0 commit comments

Comments
 (0)