Skip to content

Commit 012e654

Browse files
committed
feat: require a PKCE-style code challenge and verifier for ORCID sign-up
GetOrcidSignUpAuthorizationUrl now takes a codeChallenge (base64url SHA-256 of a random code verifier), and ConnectOrcidForSignUp sends the matching codeVerifier, as required by IQSS/dataverse#12858.
1 parent 7636f4d commit 012e654

9 files changed

Lines changed: 67 additions & 25 deletions

File tree

‎docs/useCases.md‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2792,7 +2792,9 @@ _See [use case](../src/users/domain/useCases/GetCurrentApiToken.ts) implementati
27922792

27932793
Returns the ORCID authorization URL that sends a new user to ORCID to authenticate their ORCID iD while signing up. Send the user's browser to this URL; after they sign in, ORCID redirects back to `redirectUri` with `code` and `state` query parameters, which are then passed to [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up).
27942794

2795-
No credentials are sent, so this works for anonymous users. The installation needs an ORCID OAuth2 provider (it can be disabled for login), and `redirectUri` must be listed in its `dataverse.api.signup.orcid-redirect-uris` setting.
2795+
No credentials are sent, so this works for anonymous users. The installation needs an ORCID OAuth2 provider (it can be disabled for login), and `redirectUri` must be allowed by the installation: by default, its site URL followed by `/modern/sign-up`, or a URL listed in its `dataverse.api.signup.orcid-redirect-uris` setting.
2796+
2797+
As with PKCE, create a random code verifier first and keep it (for example in `sessionStorage`). Pass its base64url-encoded SHA-256 hash as `codeChallenge`, and the verifier itself to [Connect ORCID For Sign-Up](#connect-orcid-for-sign-up). An ORCID response can then only be used by the browser that started the flow.
27962798

27972799
##### Example call:
27982800

@@ -2801,18 +2803,32 @@ import { getOrcidSignUpAuthorizationUrl } from '@iqss/dataverse-client-javascrip
28012803

28022804
/* ... */
28032805

2804-
const redirectUri = 'https://dataverse.example.edu/spa/sign-up'
2806+
const redirectUri = 'https://dataverse.example.edu/modern/sign-up'
28052807

2806-
getOrcidSignUpAuthorizationUrl.execute(redirectUri).then((authorizationUrl: string) => {
2807-
window.location.assign(authorizationUrl)
2808-
})
2808+
const toBase64Url = (bytes: Uint8Array) =>
2809+
btoa(String.fromCharCode(...bytes))
2810+
.replace(/\+/g, '-')
2811+
.replace(/\//g, '_')
2812+
.replace(/=+$/, '')
2813+
2814+
const codeVerifier = toBase64Url(crypto.getRandomValues(new Uint8Array(32)))
2815+
sessionStorage.setItem('orcidCodeVerifier', codeVerifier)
2816+
const codeChallenge = toBase64Url(
2817+
new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier)))
2818+
)
2819+
2820+
getOrcidSignUpAuthorizationUrl
2821+
.execute(redirectUri, codeChallenge)
2822+
.then((authorizationUrl: string) => {
2823+
window.location.assign(authorizationUrl)
2824+
})
28092825

28102826
/* ... */
28112827
```
28122828

28132829
_See [use case](../src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts) implementation_.
28142830

2815-
The use case throws a `ReadError` with `statusCode` 404 when no ORCID provider is configured, and 400 when the redirect URI is not allowed.
2831+
The use case throws a `ReadError` with `statusCode` 404 when no ORCID provider is configured, and 400 when the redirect URI is not allowed or the code challenge is missing or invalid.
28162832

28172833
### Users write use cases
28182834

@@ -2927,7 +2943,7 @@ The [SignUpResult](../src/users/domain/models/SignUpResult.ts) is `{ status: 'cr
29272943

29282944
### Connect ORCID For Sign-Up
29292945

2930-
Exchanges the `code` and `state` that ORCID returns to the redirect URI for the user's authenticated ORCID iD, given an [OrcidSignUpCallbackDTO](../src/users/domain/dtos/OrcidSignUpCallbackDTO.ts). Use the same `redirectUri` that was passed to [Get ORCID Sign-Up Authorization URL](#get-orcid-sign-up-authorization-url). No credentials are sent.
2946+
Exchanges the `code` and `state` that ORCID returns to the redirect URI for the user's authenticated ORCID iD, given an [OrcidSignUpCallbackDTO](../src/users/domain/dtos/OrcidSignUpCallbackDTO.ts). Use the same `redirectUri` that was passed to [Get ORCID Sign-Up Authorization URL](#get-orcid-sign-up-authorization-url), and the `codeVerifier` whose hash was passed there as `codeChallenge`. No credentials are sent.
29312947

29322948
Returns an [OrcidSignUpConnection](../src/users/domain/models/OrcidSignUpConnection.ts) with the ORCID iD, any details the user shares on their ORCID record (useful to pre-fill a sign-up form), and an `orcidToken` to include in [Sign Up](#sign-up). The token is valid for 30 minutes.
29332949

@@ -2944,7 +2960,8 @@ connectOrcidForSignUp
29442960
.execute({
29452961
code: params.get('code') as string,
29462962
state: params.get('state') as string,
2947-
redirectUri: 'https://dataverse.example.edu/spa/sign-up'
2963+
redirectUri: 'https://dataverse.example.edu/modern/sign-up',
2964+
codeVerifier: sessionStorage.getItem('orcidCodeVerifier') as string
29482965
})
29492966
.then((connection: OrcidSignUpConnection) => {
29502967
/* connection.orcid, connection.firstName, ..., connection.orcidToken */
@@ -2955,7 +2972,7 @@ connectOrcidForSignUp
29552972

29562973
_See [use case](../src/users/domain/useCases/ConnectOrcidForSignUp.ts) implementation_.
29572974

2958-
The use case throws a `WriteError` with `statusCode` 409 when the ORCID iD is already linked to another account, and 400 when the state is invalid or expired.
2975+
The use case throws a `WriteError` with `statusCode` 409 when the ORCID iD is already linked to another account, and 400 when the state is invalid or expired, or the code verifier doesn't match the code challenge.
29592976

29602977
## Roles
29612978

‎src/users/domain/dtos/OrcidSignUpCallbackDTO.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export interface OrcidSignUpCallbackDTO {
22
code: string
33
state: string
44
redirectUri: string
5+
codeVerifier: string
56
}

‎src/users/domain/repositories/IUsersRepository.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ export interface IUsersRepository {
1313
deleteCurrentApiToken(): Promise<void>
1414
registerUser(userDTO: UserDTO): Promise<void>
1515
signUp(signUpDTO: SignUpDTO): Promise<SignUpResult>
16-
getOrcidSignUpAuthorizationUrl(redirectUri: string): Promise<string>
16+
getOrcidSignUpAuthorizationUrl(redirectUri: string, codeChallenge: string): Promise<string>
1717
connectOrcidForSignUp(callback: OrcidSignUpCallbackDTO): Promise<OrcidSignUpConnection>
1818
}

‎src/users/domain/useCases/GetOrcidSignUpAuthorizationUrl.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ export class GetOrcidSignUpAuthorizationUrl implements UseCase<string> {
1313
* No authentication is used.
1414
*
1515
* @param {string} [redirectUri] - The URL ORCID redirects back to. Must be allowed by the installation's dataverse.api.signup.orcid-redirect-uris setting.
16+
* @param {string} [codeChallenge] - The base64url-encoded SHA-256 hash of a random code verifier that the client keeps and later passes to connectOrcidForSignUp, as for PKCE S256.
1617
* @returns {Promise<string>} - The ORCID authorization URL.
17-
* @throws {ReadError} - If ORCID is not configured, or the redirect URI is not allowed.
18+
* @throws {ReadError} - If ORCID is not configured, or the redirect URI or code challenge is not valid.
1819
*/
19-
async execute(redirectUri: string): Promise<string> {
20-
return await this.usersRepository.getOrcidSignUpAuthorizationUrl(redirectUri)
20+
async execute(redirectUri: string, codeChallenge: string): Promise<string> {
21+
return await this.usersRepository.getOrcidSignUpAuthorizationUrl(redirectUri, codeChallenge)
2122
}
2223
}

‎src/users/infra/repositories/UsersRepository.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,14 @@ export class UsersRepository extends ApiRepository implements IUsersRepository {
7777
})
7878
}
7979

80-
public async getOrcidSignUpAuthorizationUrl(redirectUri: string): Promise<string> {
81-
return this.doGet(`/${this.usersResourceName}/signup/orcid/authorize`, false, { redirectUri })
80+
public async getOrcidSignUpAuthorizationUrl(
81+
redirectUri: string,
82+
codeChallenge: string
83+
): Promise<string> {
84+
return this.doGet(`/${this.usersResourceName}/signup/orcid/authorize`, false, {
85+
redirectUri,
86+
codeChallenge
87+
})
8288
.then((response) => response.data.data.authorizationUrl)
8389
.catch((error) => {
8490
throw error

‎test/integration/users/UsersRepository.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,9 @@ describe('UsersRepository', () => {
201201

202202
test('getOrcidSignUpAuthorizationUrl should return a 404 ReadError', async () => {
203203
let error: ReadError | undefined = undefined
204-
await sut.getOrcidSignUpAuthorizationUrl(redirectUri).catch((e) => (error = e))
204+
await sut
205+
.getOrcidSignUpAuthorizationUrl(redirectUri, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM')
206+
.catch((e) => (error = e))
205207

206208
expect(error).toBeInstanceOf(ReadError)
207209
expect((error as unknown as ReadError).statusCode).toBe(404)
@@ -213,7 +215,12 @@ describe('UsersRepository', () => {
213215
test('connectOrcidForSignUp should return a 404 WriteError', async () => {
214216
let error: WriteError | undefined = undefined
215217
await sut
216-
.connectOrcidForSignUp({ code: 'code', state: 'state', redirectUri })
218+
.connectOrcidForSignUp({
219+
code: 'code',
220+
state: 'state',
221+
redirectUri,
222+
codeVerifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
223+
})
217224
.catch((e) => (error = e))
218225

219226
expect(error).toBeInstanceOf(WriteError)

‎test/unit/users/ConnectOrcidForSignUp.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ describe('execute', () => {
66
const callback: OrcidSignUpCallbackDTO = {
77
code: 'orcid-code',
88
state: 'orcid-state',
9-
redirectUri: 'http://127.0.0.1:8000/spa/sign-up'
9+
redirectUri: 'http://127.0.0.1:8000/spa/sign-up',
10+
codeVerifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
1011
}
1112

1213
test('should return the ORCID connection on repository success', async () => {

‎test/unit/users/GetOrcidSignUpAuthorizationUrl.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ReadError } from '../../../src'
44

55
describe('execute', () => {
66
const redirectUri = 'http://127.0.0.1:8000/spa/sign-up'
7+
const codeChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'
78

89
test('should return the authorization URL on repository success', async () => {
910
const usersRepositoryStub: IUsersRepository = {} as IUsersRepository
@@ -12,9 +13,12 @@ describe('execute', () => {
1213
.mockResolvedValue('https://orcid.org/oauth/authorize?client_id=APP-1')
1314
const sut = new GetOrcidSignUpAuthorizationUrl(usersRepositoryStub)
1415

15-
const actual = await sut.execute(redirectUri)
16+
const actual = await sut.execute(redirectUri, codeChallenge)
1617

17-
expect(usersRepositoryStub.getOrcidSignUpAuthorizationUrl).toHaveBeenCalledWith(redirectUri)
18+
expect(usersRepositoryStub.getOrcidSignUpAuthorizationUrl).toHaveBeenCalledWith(
19+
redirectUri,
20+
codeChallenge
21+
)
1822
expect(actual).toBe('https://orcid.org/oauth/authorize?client_id=APP-1')
1923
})
2024

@@ -25,6 +29,6 @@ describe('execute', () => {
2529
.mockRejectedValue(new ReadError())
2630
const sut = new GetOrcidSignUpAuthorizationUrl(usersRepositoryStub)
2731

28-
await expect(sut.execute(redirectUri)).rejects.toThrow(ReadError)
32+
await expect(sut.execute(redirectUri, codeChallenge)).rejects.toThrow(ReadError)
2933
})
3034
})

‎test/unit/users/UsersRepository.test.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ describe('signUp', () => {
185185
describe('getOrcidSignUpAuthorizationUrl', () => {
186186
const sut: UsersRepository = new UsersRepository()
187187
const redirectUri = 'http://127.0.0.1:8000/spa/sign-up'
188+
const codeChallenge = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'
188189

189190
beforeEach(() => {
190191
ApiConfig.init(
@@ -203,11 +204,14 @@ describe('getOrcidSignUpAuthorizationUrl', () => {
203204
}
204205
})
205206

206-
const actual = await sut.getOrcidSignUpAuthorizationUrl(redirectUri)
207+
const actual = await sut.getOrcidSignUpAuthorizationUrl(redirectUri, codeChallenge)
207208

208209
expect(axios.get).toHaveBeenCalledWith(
209210
`${TestConstants.TEST_API_URL}/users/signup/orcid/authorize`,
210-
{ ...TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG, params: { redirectUri } }
211+
{
212+
...TestConstants.TEST_EXPECTED_UNAUTHENTICATED_REQUEST_CONFIG,
213+
params: { redirectUri, codeChallenge }
214+
}
211215
)
212216
expect(actual).toBe('https://orcid.org/oauth/authorize?client_id=APP-1')
213217
})
@@ -221,7 +225,7 @@ describe('getOrcidSignUpAuthorizationUrl', () => {
221225
})
222226

223227
let error: ReadError | undefined = undefined
224-
await sut.getOrcidSignUpAuthorizationUrl(redirectUri).catch((e) => (error = e))
228+
await sut.getOrcidSignUpAuthorizationUrl(redirectUri, codeChallenge).catch((e) => (error = e))
225229

226230
expect(error).toBeInstanceOf(ReadError)
227231
expect((error as unknown as ReadError).statusCode).toBe(404)
@@ -234,7 +238,8 @@ describe('connectOrcidForSignUp', () => {
234238
const callback = {
235239
code: 'orcid-code',
236240
state: 'orcid-state',
237-
redirectUri: 'http://127.0.0.1:8000/spa/sign-up'
241+
redirectUri: 'http://127.0.0.1:8000/spa/sign-up',
242+
codeVerifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
238243
}
239244

240245
beforeEach(() => {

0 commit comments

Comments
 (0)