Skip to content

Commit 0a69af2

Browse files
committed
v1.3.0: larger state salt; larger keygrip secrets; native base64 handling
1 parent e7e4039 commit 0a69af2

File tree

6 files changed

+858
-473
lines changed

6 files changed

+858
-473
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## 1.3.0 (2025-02-13)
2+
3+
Changes:
4+
* larger, variable-length random bytes in state
5+
* using [keygrip-autorotate](https://github.com/justlep/keygrip-autorotate) v1.2.0 for larger keygrip secrets
6+
* native `base64url` handling
7+
* Breaking: Node 16+ required
8+
9+
## 1.2.1 (2024-09-30)
10+
* ESM only

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ When authentication is started for a _known_ email address, that address is expe
99
Github account's _primary, verified_ email address, otherwise authentication fails.
1010

1111
### Requirements
12-
* Node 14+
12+
* Node 16+
1313
* Express (or similar, it's up to you)
1414
* Your Github OAuth app providing Client ID and Client secret.
1515
See: https://github.com/settings/developers

index.js

Lines changed: 17 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,25 @@
11
import assert from 'node:assert';
22
import https from 'node:https';
3-
import crypto from 'node:crypto';
43
import {URL, URLSearchParams} from 'node:url';
5-
import KeygripAutorotate from 'keygrip-autorotate';
4+
import {KeygripAutorotate, generateRandomBytes} from 'keygrip-autorotate';
65

7-
const STATE_RANDOM_BYTES = 5;
8-
const STATE_RANDOM_BYTES_STRING_LENGTH = STATE_RANDOM_BYTES * 2;
9-
const MIN_ACCEPTED_STATE_LENGTH = STATE_RANDOM_BYTES_STRING_LENGTH + 29;
10-
const MAX_ACCEPTED_STATE_LENGTH = 3500;
6+
const STATE_SALT_MIN_BYTES = 64;
7+
const STATE_SALT_MAX_BYTES = 128;
8+
const STATE_MIN_PLAUSIBLE_LENGTH = STATE_SALT_MIN_BYTES + 6; // just a rough number, base64 overhead and email payload not even included
119
const DEFAULT_MAX_LOGIN_PROCESS_DURATION = 2 * 60 * 1000; // max. 2 minutes to enter credentials & hit Authorize
1210
const DEFAULT_SCOPES = ['user:email'];
13-
const ANY_EMAIL_PLACEHOLDER = '@' + crypto.randomBytes(10).toString('hex');
14-
15-
/**
16-
* @param {string} s
17-
* @return {string}
18-
*/
19-
export function encodeSafeBase64(s) {
20-
return Buffer.from(s, 'utf8').toString('base64').replace(/[/+=]/g, s => s === '/' ? '_' : s === '+' ? '-' : '');
21-
}
22-
23-
/**
24-
* @param {string} s
25-
* @return {?string}
26-
*/
27-
export function decodeUrlSafeBase64(s) {
28-
if (typeof s !== 'string') {
29-
return null;
30-
}
31-
return Buffer.from(s.replace(/[-_]/g, s => s === '-' ? '+' : s === '_' ? '/' : ''), 'base64').toString('utf8');
32-
}
11+
const ANY_EMAIL_PLACEHOLDER = '@' + generateRandomBytes(10, 20).toString('base64url');
3312

3413
/**
3514
* @param {string} payload - the plaintext value to put into the state
3615
* @param {KeygripAutorotate} signer
3716
* @return {string} a url-safe, signed state string that can be verified+decoded using {@link getPayloadFromStateIfVerified}
3817
*/
3918
export function createSignedStateForPayload(payload, signer) {
40-
let encodedPayload = encodeSafeBase64(payload || ''),
41-
randomPrefix = crypto.randomBytes(STATE_RANDOM_BYTES).toString('hex'),
42-
textToSign = encodedPayload.length.toString(36) + '_' + randomPrefix + encodedPayload;
19+
let encodedPayload = Buffer.from(payload || '').toString('base64url'),
20+
encodedSalt = generateRandomBytes(STATE_SALT_MIN_BYTES, STATE_SALT_MAX_BYTES).toString('base64url'),
21+
textToSign = encodedPayload.length.toString(36).padStart(3, '0') +
22+
encodedSalt.length.toString(36).padStart(3, '0') + encodedPayload + encodedSalt;
4323

4424
return textToSign + signer.sign(textToSign);
4525
}
@@ -51,26 +31,21 @@ export function createSignedStateForPayload(payload, signer) {
5131
* @return {?string} the payload from the signed state IF the state could be verified, otherwise null
5232
*/
5333
export function getPayloadFromStateIfVerified(state, signer) {
54-
if (!state || typeof state !== 'string' || state.length < MIN_ACCEPTED_STATE_LENGTH || state.length > MAX_ACCEPTED_STATE_LENGTH) {
34+
if (typeof state !== 'string' || state.length < STATE_MIN_PLAUSIBLE_LENGTH || state.length > 3500) {
5535
return null;
5636
}
5737
let payload = null;
5838
try {
59-
let lengthDividerIndex = state.indexOf('_');
60-
if (lengthDividerIndex > 3) {
61-
// encoded payload length can never be 36^4+
62-
return null;
63-
}
64-
let encodedPayloadLength = parseInt(state.substr(0, lengthDividerIndex), 36),
65-
encodedPayload = state.substr(lengthDividerIndex + 1 + STATE_RANDOM_BYTES_STRING_LENGTH, encodedPayloadLength),
66-
signedTextLength = lengthDividerIndex + 1 + STATE_RANDOM_BYTES_STRING_LENGTH + encodedPayloadLength,
67-
signedText = state.substr(0, signedTextLength),
68-
signature = state.substr(signedTextLength);
39+
let encodedPayloadLength = parseInt(state.substring(0, 3), 36),
40+
encodedSaltLength = parseInt(state.substring(3, 6), 36),
41+
signedTextLength = 6 + encodedPayloadLength + encodedSaltLength,
42+
encodedPayload = state.substring(6, 6 + encodedPayloadLength),
43+
signedText = state.substring(0, signedTextLength),
44+
signature = state.substring(signedTextLength);
6945

7046
// assert.equal(signedText.length + signature.length, state.length);
71-
7247
if (signer.verify(signedText, signature)) {
73-
payload = decodeUrlSafeBase64(encodedPayload);
48+
payload = Buffer.from(encodedPayload, 'base64url').toString('utf8');
7449
}
7550
} catch (err) {
7651
// nothing

0 commit comments

Comments
 (0)