codeforlife 2.6.17 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,13 +7,20 @@ on:
7
7
  - '**/*.code-*'
8
8
  - '.vscode/**'
9
9
  - '.devcontainer.json'
10
+ pull_request:
11
+ issues:
12
+ issue_comment:
10
13
  workflow_dispatch:
11
14
 
12
15
  jobs:
16
+ issue:
17
+ uses: ocadotechnology/codeforlife-workspace/.github/workflows/issues.yaml@main
18
+ secrets: inherit
19
+
13
20
  contributor:
14
21
  uses: ocadotechnology/codeforlife-contributor-backend/.github/workflows/check-pr-authors-signed-latest-agreement.yaml@main
15
22
 
16
- test:
23
+ test:
17
24
  uses: ocadotechnology/codeforlife-workspace/.github/workflows/test-javascript-code.yaml@main
18
25
  secrets: inherit
19
26
  with:
@@ -44,3 +51,8 @@ jobs:
44
51
  needs: [release]
45
52
  with:
46
53
  node-version: 22
54
+
55
+ submodule:
56
+ uses: ocadotechnology/codeforlife-workspace/.github/workflows/gitmodules.yaml@main
57
+ secrets: inherit
58
+ needs: [release]
package/.prettierignore CHANGED
@@ -1,5 +1 @@
1
- **/*.json
2
- **/*.yaml
3
- **/*.yml
4
- **/*.code-*
5
- **/*.md
1
+ src/scripts/*
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [2.7.1](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.7.0...v2.7.1) (2025-08-15)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * optionally set useSessionMetadata ([22c5253](https://github.com/ocadotechnology/codeforlife-package-javascript/commit/22c525341badef6f13fe9adc1489d033792d2340))
7
+
8
+ # [2.7.0](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.17...v2.7.0) (2025-08-15)
9
+
10
+
11
+ ### Features
12
+
13
+ * oauth2 support ([#87](https://github.com/ocadotechnology/codeforlife-package-javascript/issues/87)) ([6332e5c](https://github.com/ocadotechnology/codeforlife-package-javascript/commit/6332e5c9583a4936066e4144a2436af59fdaa46c))
14
+
1
15
  ## [2.6.17](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.16...v2.6.17) (2025-06-25)
2
16
 
3
17
 
package/package.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "name": "codeforlife",
3
3
  "description": "Common frontend code",
4
4
  "private": false,
5
- "version": "2.6.17",
5
+ "version": "2.7.1",
6
6
  "type": "module",
7
7
  "scripts": {
8
- "cli": "VITE_CONFIG=./vite.config.ts ../scripts/frontend/cli $@"
8
+ "cli": "VITE_CONFIG=./vite.config.ts ../scripts/frontend $@"
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
@@ -2,6 +2,12 @@ import { type EndpointBuilder, type Api } from "@reduxjs/toolkit/query/react"
2
2
 
3
3
  import { login, logout } from "../../slices/session"
4
4
 
5
+ export type ExchangeOAuth2CodeArg = {
6
+ code: string
7
+ code_verifier: string
8
+ redirect_uri: string
9
+ }
10
+
5
11
  export function buildLoginEndpoint<ResultType, QueryArg>(
6
12
  build: EndpointBuilder<any, any, any>,
7
13
  url: string = "session/login/",
@@ -1,12 +1,29 @@
1
+ import * as yup from "yup"
1
2
  import Cookies from "js-cookie"
2
- import { useEffect, type ReactNode } from "react"
3
- import { createSearchParams, useLocation, useNavigate } from "react-router-dom"
3
+ import { useEffect, useState, useCallback, type ReactNode } from "react"
4
+ import { createSearchParams } from "react-router-dom"
5
+ import type { TypedUseMutation } from "@reduxjs/toolkit/query/react"
4
6
  import { useSelector } from "react-redux"
5
7
 
6
8
  import { type AuthFactor, type User } from "../api"
9
+ import { generateSecureRandomString } from "../utils/general"
10
+ import {
11
+ makeOAuth2StorageKey,
12
+ generateOAuth2CodeChallenge,
13
+ type OAuth2CodeChallengeLengths,
14
+ type OAuth2CodeChallenge,
15
+ type OAuth2RequestCodeUrlSearchParams,
16
+ type OAuth2ReceiveCodeUrlSearchParams,
17
+ } from "../utils/auth"
18
+ import { type ExchangeOAuth2CodeArg } from "../api/endpoints/session"
19
+ import { useSearchParams, useLocation, useNavigate } from "./router"
7
20
  import { SESSION_METADATA_COOKIE_NAME } from "../settings"
8
21
  import { selectIsLoggedIn } from "../slices/session"
9
22
 
23
+ // -----------------------------------------------------------------------------
24
+ // Session
25
+ // -----------------------------------------------------------------------------
26
+
10
27
  export interface SessionMetadata {
11
28
  user_id: User["id"]
12
29
  user_type: "teacher" | "student" | "indy"
@@ -85,3 +102,283 @@ export function useSession<
85
102
 
86
103
  return children
87
104
  }
105
+
106
+ // -----------------------------------------------------------------------------
107
+ // OAuth2
108
+ // -----------------------------------------------------------------------------
109
+
110
+ export function useOAuth2State(
111
+ provider: string,
112
+ length: number = 32,
113
+ storageKey: string = "state",
114
+ ): [string | undefined, () => void] {
115
+ const oAuth2StorageKey = makeOAuth2StorageKey(provider, storageKey)
116
+ const storageValue = sessionStorage.getItem(oAuth2StorageKey)
117
+
118
+ const [_state, _setState] = useState<string>()
119
+
120
+ useEffect(() => {
121
+ let state: string
122
+ if (storageValue && storageValue.length === length) {
123
+ state = storageValue
124
+ } else {
125
+ state = generateSecureRandomString(length)
126
+ sessionStorage.setItem(oAuth2StorageKey, state)
127
+ }
128
+
129
+ _setState(state)
130
+ }, [oAuth2StorageKey, storageValue, length])
131
+
132
+ const resetState = useCallback(() => {
133
+ sessionStorage.removeItem(oAuth2StorageKey)
134
+ _setState(undefined)
135
+ }, [oAuth2StorageKey])
136
+
137
+ return [_state, resetState]
138
+ }
139
+
140
+ export function useOAuth2CodeChallenge(
141
+ provider: string,
142
+ length: OAuth2CodeChallengeLengths = 128,
143
+ storageKey: string = "codeChallenge",
144
+ ): [OAuth2CodeChallenge | undefined, () => void] {
145
+ const oAuth2StorageKey = makeOAuth2StorageKey(provider, storageKey)
146
+ const storageValue = sessionStorage.getItem(oAuth2StorageKey)
147
+
148
+ const [_codeChallenge, _setCodeChallenge] = useState<OAuth2CodeChallenge>()
149
+
150
+ useEffect(() => {
151
+ let codeChallenge: OAuth2CodeChallenge | undefined
152
+ if (storageValue) {
153
+ const storageJsonValue: unknown = JSON.parse(storageValue)
154
+ if (
155
+ typeof storageJsonValue === "object" &&
156
+ storageJsonValue &&
157
+ "verifier" in storageJsonValue &&
158
+ typeof storageJsonValue.verifier == "string" &&
159
+ storageJsonValue.verifier.length === length &&
160
+ "challenge" in storageJsonValue &&
161
+ typeof storageJsonValue.challenge === "string" &&
162
+ "method" in storageJsonValue &&
163
+ storageJsonValue.method === "S256"
164
+ ) {
165
+ codeChallenge = {
166
+ verifier: storageJsonValue.verifier,
167
+ challenge: storageJsonValue.challenge,
168
+ method: storageJsonValue.method,
169
+ }
170
+ }
171
+ }
172
+
173
+ if (codeChallenge) _setCodeChallenge(codeChallenge)
174
+ else {
175
+ generateOAuth2CodeChallenge(length)
176
+ .then(codeChallenge => {
177
+ sessionStorage.setItem(
178
+ oAuth2StorageKey,
179
+ JSON.stringify(codeChallenge),
180
+ )
181
+
182
+ _setCodeChallenge(codeChallenge)
183
+ })
184
+ .catch(error => {
185
+ if (error) console.error(error)
186
+ })
187
+ }
188
+ }, [oAuth2StorageKey, storageValue, length])
189
+
190
+ const resetCodeChallenge = useCallback(() => {
191
+ sessionStorage.removeItem(oAuth2StorageKey)
192
+ _setCodeChallenge(undefined)
193
+ }, [oAuth2StorageKey])
194
+
195
+ return [_codeChallenge, resetCodeChallenge]
196
+ }
197
+
198
+ interface BaseUseOAuth2KwArgs<SessionMetadata> {
199
+ provider: string
200
+ authUri: string
201
+ clientId: string
202
+ redirectUri: string
203
+ scope: string
204
+ responseType?: "code"
205
+ accessType?: "offline"
206
+ prompt?: string
207
+ useLoginMutation: TypedUseMutation<
208
+ SessionMetadata,
209
+ ExchangeOAuth2CodeArg,
210
+ any
211
+ >
212
+ onCreateSession: (result: SessionMetadata) => void
213
+ onRetrieveSession: (metadata: SessionMetadata) => void
214
+ }
215
+
216
+ interface UseOAuth2KwArgs<SessionMetadata>
217
+ extends BaseUseOAuth2KwArgs<SessionMetadata> {
218
+ useSessionMetadata: () => SessionMetadata | undefined
219
+ }
220
+
221
+ export type OAuth2 = [string, OAuth2RequestCodeUrlSearchParams] | []
222
+
223
+ // https://datatracker.ietf.org/doc/html/rfc7636
224
+ function _useOAuth2<SessionMetadata>({
225
+ provider,
226
+ authUri,
227
+ clientId,
228
+ redirectUri,
229
+ scope,
230
+ responseType = "code",
231
+ accessType = "offline",
232
+ prompt,
233
+ useSessionMetadata,
234
+ useLoginMutation,
235
+ onCreateSession,
236
+ onRetrieveSession,
237
+ }: UseOAuth2KwArgs<SessionMetadata>): OAuth2 {
238
+ const [state, resetState] = useOAuth2State(provider)
239
+ const [
240
+ {
241
+ verifier: codeVerifier,
242
+ challenge: codeChallenge,
243
+ method: codeChallengeMethod,
244
+ } = {},
245
+ resetCodeChallenge,
246
+ ] = useOAuth2CodeChallenge(provider)
247
+ const [
248
+ login,
249
+ {
250
+ originalArgs: loginArgs = {} as ExchangeOAuth2CodeArg,
251
+ isLoading: loginIsLoading,
252
+ isError: loginIsError,
253
+ },
254
+ ] = useLoginMutation()
255
+ const sessionMetadata = useSessionMetadata()
256
+ const navigate = useNavigate()
257
+ const searchParams =
258
+ useSearchParams({ code: yup.string(), state: yup.string() }) || {}
259
+ const location = useLocation<OAuth2ReceiveCodeUrlSearchParams>()
260
+
261
+ const locationState = location.state || {}
262
+
263
+ useEffect(() => {
264
+ // If the the auth provider has redirected back to our site with the
265
+ // expected search params, we redirect to the current page to remove them.
266
+ if (searchParams.code && searchParams.state) {
267
+ navigate<OAuth2ReceiveCodeUrlSearchParams>(".", {
268
+ // Removes the URL containing the search params from the history stack.
269
+ replace: true,
270
+ // Ensure we don't break the auth flow by navigating to another page.
271
+ next: false,
272
+ // Store the search params in the page's state instead.
273
+ state: { code: searchParams.code, state: searchParams.state },
274
+ })
275
+ }
276
+ }, [searchParams.code, searchParams.state, navigate])
277
+
278
+ useEffect(() => {
279
+ // If we're already logged in, no need to log in again.
280
+ if (sessionMetadata) onRetrieveSession(sessionMetadata)
281
+ else if (
282
+ // If the state and code verifier have been generated...
283
+ state &&
284
+ codeVerifier &&
285
+ // ...and the page's state contains a code...
286
+ locationState.code &&
287
+ // ...and the page's state contains the stored state...
288
+ locationState.state === state &&
289
+ // ...and the login endpoint was not called with the current values or has
290
+ // not returned an error...
291
+ (loginArgs.code !== locationState.code ||
292
+ loginArgs.code_verifier !== codeVerifier ||
293
+ loginArgs.redirect_uri !== redirectUri ||
294
+ !loginIsError) &&
295
+ // ...and the login endpoint is not currently being called...
296
+ !loginIsLoading
297
+ ) {
298
+ // ...call the login endpoint.
299
+ login({
300
+ code: locationState.code,
301
+ code_verifier: codeVerifier,
302
+ redirect_uri: redirectUri,
303
+ })
304
+ .unwrap()
305
+ .then(onCreateSession)
306
+ .catch(() => {
307
+ navigate(".", {
308
+ replace: true,
309
+ state: {
310
+ notifications: [
311
+ {
312
+ props: {
313
+ error: true,
314
+ children: "Failed to login. Please try again.",
315
+ },
316
+ },
317
+ ],
318
+ },
319
+ })
320
+ })
321
+ .finally(() => {
322
+ resetState()
323
+ resetCodeChallenge()
324
+ })
325
+ }
326
+ }, [
327
+ navigate,
328
+ redirectUri,
329
+ // State
330
+ state,
331
+ locationState.state,
332
+ resetState,
333
+ // Code
334
+ codeVerifier,
335
+ locationState.code,
336
+ resetCodeChallenge,
337
+ // Login
338
+ login,
339
+ loginIsLoading,
340
+ loginIsError,
341
+ loginArgs.code,
342
+ loginArgs.code_verifier,
343
+ loginArgs.redirect_uri,
344
+ // Session
345
+ sessionMetadata,
346
+ onCreateSession,
347
+ onRetrieveSession,
348
+ ])
349
+
350
+ if (state && codeChallenge && codeChallengeMethod) {
351
+ const urlSearchParams: OAuth2RequestCodeUrlSearchParams = {
352
+ client_id: clientId,
353
+ redirect_uri: redirectUri,
354
+ scope,
355
+ response_type: responseType,
356
+ access_type: accessType,
357
+ state,
358
+ code_challenge: codeChallenge,
359
+ code_challenge_method: codeChallengeMethod,
360
+ }
361
+
362
+ if (prompt) urlSearchParams["prompt"] = prompt
363
+
364
+ return [
365
+ authUri + "?" + new URLSearchParams(urlSearchParams).toString(),
366
+ urlSearchParams,
367
+ ]
368
+ }
369
+
370
+ return []
371
+ }
372
+
373
+ export const useOAuth2: {
374
+ <SessionMetadata>(kwargs: UseOAuth2KwArgs<SessionMetadata>): OAuth2
375
+ (kwargs: BaseUseOAuth2KwArgs<SessionMetadata>): OAuth2
376
+ } = <_SessionMetadata,>(
377
+ kwargs:
378
+ | UseOAuth2KwArgs<_SessionMetadata>
379
+ | BaseUseOAuth2KwArgs<SessionMetadata>,
380
+ ): OAuth2 => {
381
+ return "useSessionMetadata" in kwargs
382
+ ? _useOAuth2(kwargs)
383
+ : _useOAuth2({ ...kwargs, useSessionMetadata })
384
+ }
package/src/utils/auth.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  SESSION_METADATA_COOKIE_NAME,
6
6
  CSRF_COOKIE_NAME,
7
7
  } from "../settings"
8
+ import { generateSecureRandomString } from "./general"
8
9
 
9
10
  export function logout() {
10
11
  Cookies.remove(SESSION_COOKIE_NAME)
@@ -15,3 +16,63 @@ export function logout() {
15
16
  export function getCsrfCookie() {
16
17
  return Cookies.get(CSRF_COOKIE_NAME)
17
18
  }
19
+
20
+ export function makeOAuth2StorageKey(provider: string, key: string) {
21
+ return `oauth2.${provider}.${key}`
22
+ }
23
+
24
+ export const OAUTH2_CODE_CHALLENGE_METHODS = ["S256"] as const
25
+
26
+ export type OAuth2CodeChallengeMethods =
27
+ (typeof OAUTH2_CODE_CHALLENGE_METHODS)[number]
28
+
29
+ export const OAUTH2_CODE_CHALLENGE_LENGTHS = [
30
+ 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61,
31
+ 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80,
32
+ 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99,
33
+ 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114,
34
+ 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128,
35
+ ] as const
36
+
37
+ export type OAuth2CodeChallengeLengths =
38
+ (typeof OAUTH2_CODE_CHALLENGE_LENGTHS)[number]
39
+
40
+ export type OAuth2RequestCodeUrlSearchParams = {
41
+ client_id: string
42
+ redirect_uri: string
43
+ scope: string
44
+ response_type: string
45
+ access_type: string
46
+ prompt?: string
47
+ state: string
48
+ code_challenge: string
49
+ code_challenge_method: string
50
+ }
51
+
52
+ export type OAuth2ReceiveCodeUrlSearchParams = {
53
+ code: string
54
+ state: string
55
+ }
56
+
57
+ export type OAuth2CodeChallenge = {
58
+ verifier: string
59
+ challenge: string
60
+ method: OAuth2CodeChallengeMethods
61
+ }
62
+
63
+ export async function generateOAuth2CodeChallenge(
64
+ length: OAuth2CodeChallengeLengths,
65
+ ): Promise<OAuth2CodeChallenge> {
66
+ const verifier = generateSecureRandomString(length)
67
+ const data = new TextEncoder().encode(verifier)
68
+ const digest = await window.crypto.subtle.digest("SHA-256", data)
69
+
70
+ return {
71
+ verifier,
72
+ challenge: btoa(String.fromCharCode(...new Uint8Array(digest)))
73
+ .replace(/\+/g, "-")
74
+ .replace(/\//g, "_")
75
+ .replace(/=+$/, ""),
76
+ method: "S256",
77
+ }
78
+ }
@@ -1025,3 +1025,19 @@ export function excludeKeyPaths(
1025
1025
 
1026
1026
  return exclude.length ? _excludeKeyPaths(obj, []) : obj
1027
1027
  }
1028
+
1029
+ export function generateSecureRandomString(
1030
+ length: number,
1031
+ charSet: string = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789",
1032
+ ) {
1033
+ // Create an array of 32-bit unsigned integers
1034
+ const randomValues = window.crypto.getRandomValues(new Uint8Array(length))
1035
+
1036
+ // Map the random values to characters from our string
1037
+ let result = ""
1038
+ for (let i = 0; i < length; i++) {
1039
+ result += charSet.charAt(randomValues[i] % charSet.length)
1040
+ }
1041
+
1042
+ return result
1043
+ }
package/.prettierrc.json DELETED
@@ -1,4 +0,0 @@
1
- {
2
- "semi": false,
3
- "arrowParens": "avoid"
4
- }
@@ -1,22 +0,0 @@
1
- {
2
- "configurations": [
3
- {
4
- "args": [
5
- "run",
6
- "${relativeFile}"
7
- ],
8
- "autoAttachChildProcesses": true,
9
- "console": "integratedTerminal",
10
- "name": "Vitest: Current File",
11
- "program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
12
- "request": "launch",
13
- "skipFiles": [
14
- "<node_internals>/**",
15
- "**/node_modules/**"
16
- ],
17
- "smartStep": true,
18
- "type": "node"
19
- }
20
- ],
21
- "version": "0.2.0"
22
- }
@@ -1,30 +0,0 @@
1
- {
2
- "[md]": {
3
- "editor.tabSize": 4
4
- },
5
- "cSpell.words": [
6
- "codeforlife",
7
- "klass",
8
- "ocado",
9
- "kurono",
10
- "pipenv"
11
- ],
12
- "editor.codeActionsOnSave": {
13
- "source.fixAll.eslint": "always",
14
- "source.organizeImports": "never"
15
- },
16
- "editor.defaultFormatter": "esbenp.prettier-vscode",
17
- "editor.formatOnSave": true,
18
- "editor.rulers": [
19
- 80
20
- ],
21
- "editor.tabSize": 2,
22
- "files.exclude": {
23
- "**/*.tsbuildinfo": true
24
- },
25
- "javascript.format.semicolons": "remove",
26
- "javascript.preferences.quoteStyle": "double",
27
- "prettier.configPath": ".prettierrc.json",
28
- "typescript.format.semicolons": "remove",
29
- "typescript.preferences.quoteStyle": "double"
30
- }