codeforlife 2.6.17 → 2.7.0

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,10 @@
1
+ # [2.7.0](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.17...v2.7.0) (2025-08-15)
2
+
3
+
4
+ ### Features
5
+
6
+ * oauth2 support ([#87](https://github.com/ocadotechnology/codeforlife-package-javascript/issues/87)) ([6332e5c](https://github.com/ocadotechnology/codeforlife-package-javascript/commit/6332e5c9583a4936066e4144a2436af59fdaa46c))
7
+
1
8
  ## [2.6.17](https://github.com/ocadotechnology/codeforlife-package-javascript/compare/v2.6.16...v2.6.17) (2025-06-25)
2
9
 
3
10
 
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.0",
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,13 @@ 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 ExchangeOAuth2CodeResult = null
6
+ export type ExchangeOAuth2CodeArg = {
7
+ code: string
8
+ code_verifier: string
9
+ redirect_uri: string
10
+ }
11
+
5
12
  export function buildLoginEndpoint<ResultType, QueryArg>(
6
13
  build: EndpointBuilder<any, any, any>,
7
14
  url: string = "session/login/",
@@ -1,12 +1,32 @@
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 {
19
+ type ExchangeOAuth2CodeResult,
20
+ type ExchangeOAuth2CodeArg,
21
+ } from "../api/endpoints/session"
22
+ import { useSearchParams, useLocation, useNavigate } from "./router"
7
23
  import { SESSION_METADATA_COOKIE_NAME } from "../settings"
8
24
  import { selectIsLoggedIn } from "../slices/session"
9
25
 
26
+ // -----------------------------------------------------------------------------
27
+ // Session
28
+ // -----------------------------------------------------------------------------
29
+
10
30
  export interface SessionMetadata {
11
31
  user_id: User["id"]
12
32
  user_type: "teacher" | "student" | "indy"
@@ -85,3 +105,260 @@ export function useSession<
85
105
 
86
106
  return children
87
107
  }
108
+
109
+ // -----------------------------------------------------------------------------
110
+ // OAuth2
111
+ // -----------------------------------------------------------------------------
112
+
113
+ export function useOAuth2State(
114
+ provider: string,
115
+ length: number = 32,
116
+ storageKey: string = "state",
117
+ ): [string | undefined, () => void] {
118
+ const oAuth2StorageKey = makeOAuth2StorageKey(provider, storageKey)
119
+ const storageValue = sessionStorage.getItem(oAuth2StorageKey)
120
+
121
+ const [_state, _setState] = useState<string>()
122
+
123
+ useEffect(() => {
124
+ let state: string
125
+ if (storageValue && storageValue.length === length) {
126
+ state = storageValue
127
+ } else {
128
+ state = generateSecureRandomString(length)
129
+ sessionStorage.setItem(oAuth2StorageKey, state)
130
+ }
131
+
132
+ _setState(state)
133
+ }, [oAuth2StorageKey, storageValue, length])
134
+
135
+ const resetState = useCallback(() => {
136
+ sessionStorage.removeItem(oAuth2StorageKey)
137
+ _setState(undefined)
138
+ }, [oAuth2StorageKey])
139
+
140
+ return [_state, resetState]
141
+ }
142
+
143
+ export function useOAuth2CodeChallenge(
144
+ provider: string,
145
+ length: OAuth2CodeChallengeLengths = 128,
146
+ storageKey: string = "codeChallenge",
147
+ ): [OAuth2CodeChallenge | undefined, () => void] {
148
+ const oAuth2StorageKey = makeOAuth2StorageKey(provider, storageKey)
149
+ const storageValue = sessionStorage.getItem(oAuth2StorageKey)
150
+
151
+ const [_codeChallenge, _setCodeChallenge] = useState<OAuth2CodeChallenge>()
152
+
153
+ useEffect(() => {
154
+ let codeChallenge: OAuth2CodeChallenge | undefined
155
+ if (storageValue) {
156
+ const storageJsonValue: unknown = JSON.parse(storageValue)
157
+ if (
158
+ typeof storageJsonValue === "object" &&
159
+ storageJsonValue &&
160
+ "verifier" in storageJsonValue &&
161
+ typeof storageJsonValue.verifier == "string" &&
162
+ storageJsonValue.verifier.length === length &&
163
+ "challenge" in storageJsonValue &&
164
+ typeof storageJsonValue.challenge === "string" &&
165
+ "method" in storageJsonValue &&
166
+ storageJsonValue.method === "S256"
167
+ ) {
168
+ codeChallenge = {
169
+ verifier: storageJsonValue.verifier,
170
+ challenge: storageJsonValue.challenge,
171
+ method: storageJsonValue.method,
172
+ }
173
+ }
174
+ }
175
+
176
+ if (codeChallenge) _setCodeChallenge(codeChallenge)
177
+ else {
178
+ generateOAuth2CodeChallenge(length)
179
+ .then(codeChallenge => {
180
+ sessionStorage.setItem(
181
+ oAuth2StorageKey,
182
+ JSON.stringify(codeChallenge),
183
+ )
184
+
185
+ _setCodeChallenge(codeChallenge)
186
+ })
187
+ .catch(error => {
188
+ if (error) console.error(error)
189
+ })
190
+ }
191
+ }, [oAuth2StorageKey, storageValue, length])
192
+
193
+ const resetCodeChallenge = useCallback(() => {
194
+ sessionStorage.removeItem(oAuth2StorageKey)
195
+ _setCodeChallenge(undefined)
196
+ }, [oAuth2StorageKey])
197
+
198
+ return [_codeChallenge, resetCodeChallenge]
199
+ }
200
+
201
+ export interface UseOAuth2KwArgs<ResultType = ExchangeOAuth2CodeResult> {
202
+ provider: string
203
+ authUri: string
204
+ clientId: string
205
+ redirectUri: string
206
+ scope: string
207
+ responseType?: "code"
208
+ accessType?: "offline"
209
+ prompt?: string
210
+ useLoginMutation: TypedUseMutation<ResultType, ExchangeOAuth2CodeArg, any>
211
+ onCreateSession: (result: ResultType) => void
212
+ onRetrieveSession: (metadata: SessionMetadata) => void
213
+ }
214
+
215
+ export type OAuth2 = [string, OAuth2RequestCodeUrlSearchParams] | []
216
+
217
+ // https://datatracker.ietf.org/doc/html/rfc7636
218
+ export function useOAuth2<ResultType = ExchangeOAuth2CodeResult>({
219
+ provider,
220
+ authUri,
221
+ clientId,
222
+ redirectUri,
223
+ scope,
224
+ responseType = "code",
225
+ accessType = "offline",
226
+ prompt,
227
+ useLoginMutation,
228
+ onCreateSession,
229
+ onRetrieveSession,
230
+ }: UseOAuth2KwArgs<ResultType>): OAuth2 {
231
+ const [state, resetState] = useOAuth2State(provider)
232
+ const [
233
+ {
234
+ verifier: codeVerifier,
235
+ challenge: codeChallenge,
236
+ method: codeChallengeMethod,
237
+ } = {},
238
+ resetCodeChallenge,
239
+ ] = useOAuth2CodeChallenge(provider)
240
+ const [
241
+ login,
242
+ {
243
+ originalArgs: loginArgs = {} as ExchangeOAuth2CodeArg,
244
+ isLoading: loginIsLoading,
245
+ isError: loginIsError,
246
+ },
247
+ ] = useLoginMutation()
248
+ const sessionMetadata = useSessionMetadata()
249
+ const navigate = useNavigate()
250
+ const searchParams =
251
+ useSearchParams({ code: yup.string(), state: yup.string() }) || {}
252
+ const location = useLocation<OAuth2ReceiveCodeUrlSearchParams>()
253
+
254
+ const locationState = location.state || {}
255
+
256
+ useEffect(() => {
257
+ // If the the auth provider has redirected back to our site with the
258
+ // expected search params, we redirect to the current page to remove them.
259
+ if (searchParams.code && searchParams.state) {
260
+ navigate<OAuth2ReceiveCodeUrlSearchParams>(".", {
261
+ // Removes the URL containing the search params from the history stack.
262
+ replace: true,
263
+ // Ensure we don't break the auth flow by navigating to another page.
264
+ next: false,
265
+ // Store the search params in the page's state instead.
266
+ state: { code: searchParams.code, state: searchParams.state },
267
+ })
268
+ }
269
+ }, [searchParams.code, searchParams.state, navigate])
270
+
271
+ useEffect(() => {
272
+ // If we're already logged in, no need to log in again.
273
+ if (sessionMetadata) onRetrieveSession(sessionMetadata)
274
+ else if (
275
+ // If the state and code verifier have been generated...
276
+ state &&
277
+ codeVerifier &&
278
+ // ...and the page's state contains a code...
279
+ locationState.code &&
280
+ // ...and the page's state contains the stored state...
281
+ locationState.state === state &&
282
+ // ...and the login endpoint was not called with the current values or has
283
+ // not returned and error...
284
+ (loginArgs.code !== locationState.code ||
285
+ loginArgs.code_verifier !== codeVerifier ||
286
+ loginArgs.redirect_uri !== redirectUri ||
287
+ !loginIsError) &&
288
+ // ...and the login endpoint is not currently being called...
289
+ !loginIsLoading
290
+ ) {
291
+ // ...call the login endpoint.
292
+ login({
293
+ code: locationState.code,
294
+ code_verifier: codeVerifier,
295
+ redirect_uri: redirectUri,
296
+ })
297
+ .unwrap()
298
+ .then(onCreateSession)
299
+ .catch(() => {
300
+ navigate(".", {
301
+ replace: true,
302
+ state: {
303
+ notifications: [
304
+ {
305
+ props: {
306
+ error: true,
307
+ children: "Failed to login. Please try again.",
308
+ },
309
+ },
310
+ ],
311
+ },
312
+ })
313
+ })
314
+ .finally(() => {
315
+ resetState()
316
+ resetCodeChallenge()
317
+ })
318
+ }
319
+ }, [
320
+ navigate,
321
+ redirectUri,
322
+ // State
323
+ state,
324
+ locationState.state,
325
+ resetState,
326
+ // Code
327
+ codeVerifier,
328
+ locationState.code,
329
+ resetCodeChallenge,
330
+ // Login
331
+ login,
332
+ loginIsLoading,
333
+ loginIsError,
334
+ loginArgs.code,
335
+ loginArgs.code_verifier,
336
+ loginArgs.redirect_uri,
337
+ // Session
338
+ sessionMetadata,
339
+ onCreateSession,
340
+ onRetrieveSession,
341
+ ])
342
+
343
+ if (state && codeChallenge && codeChallengeMethod) {
344
+ const urlSearchParams: OAuth2RequestCodeUrlSearchParams = {
345
+ client_id: clientId,
346
+ redirect_uri: redirectUri,
347
+ scope,
348
+ response_type: responseType,
349
+ access_type: accessType,
350
+ state,
351
+ code_challenge: codeChallenge,
352
+ code_challenge_method: codeChallengeMethod,
353
+ }
354
+
355
+ if (prompt) urlSearchParams["prompt"] = prompt
356
+
357
+ return [
358
+ authUri + "?" + new URLSearchParams(urlSearchParams).toString(),
359
+ urlSearchParams,
360
+ ]
361
+ }
362
+
363
+ return []
364
+ }
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
- }