toiljs 0.0.117 → 0.0.118
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.
- package/CHANGELOG.md +9 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.d.ts +3 -0
- package/build/client/auth.js +19 -10
- package/build/client/index.d.ts +1 -1
- package/build/client/index.js +1 -1
- package/build/client/navigation/navigation.js +28 -3
- package/build/client/routing/Router.js +5 -2
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/index.js +1 -1
- package/build/compiler/toil-docs.generated.js +2 -2
- package/build/compiler/vite.js +1 -0
- package/examples/basic/client/routes/pq.tsx +2 -1
- package/package.json +1 -1
- package/server/auth/AuthController.ts +53 -14
- package/server/auth/FactorSettings.ts +41 -0
- package/server/auth/SessionEpochs.ts +58 -0
- package/server/auth/SessionRevocations.ts +13 -0
- package/server/globals/auth.ts +27 -2
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { FactorRepository } from './FactorSettings';
|
|
2
|
+
import { SessionEpochs } from './SessionEpochs';
|
|
1
3
|
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
2
4
|
import { DataReader, DataWriter } from 'data';
|
|
3
5
|
|
|
@@ -500,6 +502,8 @@ class Challenge {
|
|
|
500
502
|
nonce: Uint8Array = new Uint8Array(0);
|
|
501
503
|
iat: u64 = 0;
|
|
502
504
|
exp: u64 = 0;
|
|
505
|
+
sessionEpoch: u64 = 0;
|
|
506
|
+
sessionVersion: u8 = 0;
|
|
503
507
|
}
|
|
504
508
|
|
|
505
509
|
/** The lookup key for a login 2FA challenge: 16 random bytes minted per login.
|
|
@@ -531,6 +535,9 @@ class TwoFaChallenge {
|
|
|
531
535
|
exp: u64 = 0;
|
|
532
536
|
attempts: u32 = 0;
|
|
533
537
|
targetMethod: u8 = 0;
|
|
538
|
+
sessionEpoch: u64 = 0;
|
|
539
|
+
sessionVersion: u8 = 0;
|
|
540
|
+
settingsRevision: u64 = 0;
|
|
534
541
|
}
|
|
535
542
|
|
|
536
543
|
@database
|
|
@@ -729,6 +736,9 @@ class Auth {
|
|
|
729
736
|
c.nonce = nonce;
|
|
730
737
|
c.iat = iat;
|
|
731
738
|
c.exp = exp;
|
|
739
|
+
const account = AuthDb.accounts.get(new Username(h, username))!;
|
|
740
|
+
c.sessionEpoch = SessionEpochs.current(__toilEncodeAuthUser(this.stableUserId(ctx, h, username, account), username));
|
|
741
|
+
c.sessionVersion = 2;
|
|
732
742
|
AuthDb.challenges.create(new ChallengeId(h, cid), c);
|
|
733
743
|
}
|
|
734
744
|
|
|
@@ -763,6 +773,7 @@ class Auth {
|
|
|
763
773
|
// The challenge is realm-keyed, so one minted on another domain misses.
|
|
764
774
|
const ch = AuthDb.challenges.getDelete(new ChallengeId(h, cid));
|
|
765
775
|
if (ch == null) return fail();
|
|
776
|
+
if (ch.sessionVersion != 2) return fail();
|
|
766
777
|
if (nowSecs() >= ch.exp) return fail();
|
|
767
778
|
|
|
768
779
|
// 2. Rebuild the message from OUR stored values + the client's ct (and
|
|
@@ -810,11 +821,12 @@ class Auth {
|
|
|
810
821
|
// never resetTokens/confirmTokens). This runs AFTER the signature
|
|
811
822
|
// verify above, so it is not an oracle, and the login `Challenge` was
|
|
812
823
|
// already consumed (getDelete) at the top of this handler.
|
|
813
|
-
|
|
824
|
+
const factorMethod = FactorRepository.read(h, ch.username, acct.twoFactorMethod).method;
|
|
825
|
+
if (factorMethod != TWOFA_NONE) {
|
|
814
826
|
const raw = randomBytes(16); // the twoFaId the client echoes to /2fa/verify
|
|
815
827
|
const codeHash = this.twoFaDeliver(
|
|
816
828
|
h,
|
|
817
|
-
|
|
829
|
+
factorMethod,
|
|
818
830
|
ch.username,
|
|
819
831
|
acct.email,
|
|
820
832
|
'Your login code',
|
|
@@ -823,10 +835,12 @@ class Auth {
|
|
|
823
835
|
if (codeHash.length == 0) return fail(); // unsupported/misconfigured method
|
|
824
836
|
const c2 = new TwoFaChallenge();
|
|
825
837
|
c2.username = ch.username;
|
|
826
|
-
c2.method =
|
|
838
|
+
c2.method = factorMethod;
|
|
827
839
|
c2.codeHash = codeHash;
|
|
828
840
|
c2.exp = nowSecs() + TWOFA_TTL_SECS;
|
|
829
841
|
c2.attempts = 0;
|
|
842
|
+
c2.sessionEpoch = ch.sessionEpoch;
|
|
843
|
+
c2.sessionVersion = 2;
|
|
830
844
|
c2.targetMethod = TWOFA_NONE; // a LOGIN challenge changes no method on success
|
|
831
845
|
AuthDb.twoFaLogins.create(new TwoFaId(h, raw), c2);
|
|
832
846
|
|
|
@@ -848,7 +862,7 @@ class Auth {
|
|
|
848
862
|
w.writeBytes(userData); // opaque session token (the readable user payload)
|
|
849
863
|
w.writeBytes(confirm);
|
|
850
864
|
const resp = Response.bytes(w.toBytes());
|
|
851
|
-
resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
|
|
865
|
+
resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS, ch.sessionEpoch));
|
|
852
866
|
resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
|
|
853
867
|
return resp;
|
|
854
868
|
}
|
|
@@ -951,9 +965,11 @@ class Auth {
|
|
|
951
965
|
// Overwrite ONLY the login verifier. Salt/params are deterministic
|
|
952
966
|
// constants (unchanged); a successful reset also implies email ownership,
|
|
953
967
|
// so confirm the account while we are here.
|
|
968
|
+
const stableId = this.stableUserId(ctx, h, rec.username, acct);
|
|
954
969
|
acct.publicKey = pk;
|
|
955
970
|
acct.emailConfirmed = true;
|
|
956
971
|
AuthDb.accounts.patch(new Username(h, rec.username), acct);
|
|
972
|
+
AuthService.revokeUserSessions(__toilEncodeAuthUser(stableId, rec.username));
|
|
957
973
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
958
974
|
}
|
|
959
975
|
|
|
@@ -986,6 +1002,18 @@ class Auth {
|
|
|
986
1002
|
@auth
|
|
987
1003
|
@post('/logout')
|
|
988
1004
|
public logout(_ctx: RouteContext): Response {
|
|
1005
|
+
AuthService.revokeSession();
|
|
1006
|
+
const resp = Response.text('bye\n', 200);
|
|
1007
|
+
resp.setCookie(AuthService.clearSession());
|
|
1008
|
+
resp.setCookie(AuthService.clearUserCookie());
|
|
1009
|
+
return resp;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** Revoke all sessions, including a copied cookie and pending pre-revocation logins. */
|
|
1013
|
+
@auth
|
|
1014
|
+
@post('/logout-all')
|
|
1015
|
+
public logoutAll(_ctx: RouteContext): Response {
|
|
1016
|
+
AuthService.revokeUserSessions(AuthService.getSessionBytes()!);
|
|
989
1017
|
const resp = Response.text('bye\n', 200);
|
|
990
1018
|
resp.setCookie(AuthService.clearSession());
|
|
991
1019
|
resp.setCookie(AuthService.clearUserCookie());
|
|
@@ -1029,6 +1057,7 @@ class Auth {
|
|
|
1029
1057
|
// would otherwise lock the user out) until the attempt cap is reached.
|
|
1030
1058
|
const ch = AuthDb.twoFaLogins.get(key);
|
|
1031
1059
|
if (ch == null) return fail();
|
|
1060
|
+
if (ch.sessionVersion != 2) return fail(); // reject pre-upgrade pending logins
|
|
1032
1061
|
if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
|
|
1033
1062
|
AuthDb.twoFaLogins.delete(key); // burn an expired / exhausted challenge
|
|
1034
1063
|
return fail();
|
|
@@ -1043,7 +1072,8 @@ class Auth {
|
|
|
1043
1072
|
}
|
|
1044
1073
|
|
|
1045
1074
|
// SUCCESS: consume-once, then mint the session EXACTLY like login/finish.
|
|
1046
|
-
AuthDb.twoFaLogins.getDelete(key);
|
|
1075
|
+
const consumed = AuthDb.twoFaLogins.getDelete(key);
|
|
1076
|
+
if (consumed == null) return fail();
|
|
1047
1077
|
const acct = AuthDb.accounts.get(new Username(h, ch.username));
|
|
1048
1078
|
if (acct == null) return fail();
|
|
1049
1079
|
const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
|
|
@@ -1052,7 +1082,7 @@ class Auth {
|
|
|
1052
1082
|
w.writeU8(ST_OK);
|
|
1053
1083
|
w.writeBytes(userData);
|
|
1054
1084
|
const resp = Response.bytes(w.toBytes());
|
|
1055
|
-
resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS));
|
|
1085
|
+
resp.setCookie(AuthService.mintSession(userData, SESSION_TTL_SECS, ch.sessionEpoch));
|
|
1056
1086
|
resp.setCookie(AuthService.userCookie(userData, SESSION_TTL_SECS));
|
|
1057
1087
|
return resp;
|
|
1058
1088
|
}
|
|
@@ -1078,8 +1108,9 @@ class Auth {
|
|
|
1078
1108
|
const acct = AuthDb.accounts.get(new Username(h, u.username));
|
|
1079
1109
|
if (acct == null) return fail();
|
|
1080
1110
|
|
|
1111
|
+
const settings = FactorRepository.read(h, u.username, acct.twoFactorMethod);
|
|
1081
1112
|
// Disabling when 2FA is already off: nothing to prove; generic ST_OK.
|
|
1082
|
-
if (targetMethod == TWOFA_NONE &&
|
|
1113
|
+
if (targetMethod == TWOFA_NONE && settings.method == TWOFA_NONE) {
|
|
1083
1114
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
1084
1115
|
}
|
|
1085
1116
|
|
|
@@ -1087,7 +1118,7 @@ class Auth {
|
|
|
1087
1118
|
// ENABLE (targetMethod != NONE): the NEW method (prove you control it).
|
|
1088
1119
|
// DISABLE (targetMethod == NONE): the CURRENT method (prove you still
|
|
1089
1120
|
// hold the existing factor before it is removed).
|
|
1090
|
-
const deliverMethod: u8 = targetMethod != TWOFA_NONE ? targetMethod :
|
|
1121
|
+
const deliverMethod: u8 = targetMethod != TWOFA_NONE ? targetMethod : settings.method;
|
|
1091
1122
|
// Action phrase for the 2FA email body (the `{action}` token): enabling proves the
|
|
1092
1123
|
// new factor, disabling proves the current one before it is removed.
|
|
1093
1124
|
const twoFaAction: string =
|
|
@@ -1106,6 +1137,9 @@ class Auth {
|
|
|
1106
1137
|
|
|
1107
1138
|
const c = new TwoFaChallenge();
|
|
1108
1139
|
c.username = u.username;
|
|
1140
|
+
c.settingsRevision = settings.revision;
|
|
1141
|
+
c.sessionVersion = 2;
|
|
1142
|
+
c.sessionEpoch = SessionEpochs.current(AuthService.getSessionBytes()!);
|
|
1109
1143
|
c.method = deliverMethod;
|
|
1110
1144
|
c.codeHash = codeHash;
|
|
1111
1145
|
c.exp = nowSecs() + TWOFA_TTL_SECS;
|
|
@@ -1148,7 +1182,7 @@ class Auth {
|
|
|
1148
1182
|
|
|
1149
1183
|
const key = new Username(h, u.username);
|
|
1150
1184
|
const ch = AuthDb.twoFaSetup.get(key);
|
|
1151
|
-
if (ch == null) return fail();
|
|
1185
|
+
if (ch == null || ch.sessionVersion != 2 || !SessionEpochs.allowed(AuthService.getSessionBytes()!, ch.sessionEpoch)) return fail();
|
|
1152
1186
|
if (nowSecs() >= ch.exp || ch.attempts >= TWOFA_MAX_ATTEMPTS) {
|
|
1153
1187
|
AuthDb.twoFaSetup.delete(key);
|
|
1154
1188
|
return fail();
|
|
@@ -1161,12 +1195,17 @@ class Auth {
|
|
|
1161
1195
|
}
|
|
1162
1196
|
|
|
1163
1197
|
// SUCCESS: consume-once + apply the method change.
|
|
1164
|
-
AuthDb.twoFaSetup.getDelete(key);
|
|
1198
|
+
const consumed = AuthDb.twoFaSetup.getDelete(key);
|
|
1199
|
+
if (consumed == null || nowSecs() >= consumed.exp || !this.twoFaVerifyCode(h, consumed.username, code, consumed)) return fail();
|
|
1165
1200
|
const acct = AuthDb.accounts.get(key);
|
|
1166
1201
|
if (acct == null) return fail();
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1202
|
+
if (!FactorRepository.save(h, u.username, consumed.targetMethod, consumed.settingsRevision)) return fail();
|
|
1203
|
+
const userData = __toilEncodeAuthUser(this.stableUserId(_ctx, h, u.username, acct), u.username);
|
|
1204
|
+
AuthService.revokeUserSessions(userData);
|
|
1205
|
+
const response = Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
1206
|
+
response.setCookie(AuthService.clearSession());
|
|
1207
|
+
response.setCookie(AuthService.clearUserCookie());
|
|
1208
|
+
return response;
|
|
1170
1209
|
}
|
|
1171
1210
|
|
|
1172
1211
|
/** GET /auth/2fa/status (@auth) resp: u8(method) (0 = off, 1 = email, ...)
|
|
@@ -1179,7 +1218,7 @@ class Auth {
|
|
|
1179
1218
|
const h = realm(_ctx);
|
|
1180
1219
|
const acct = AuthDb.accounts.get(new Username(h, u.username));
|
|
1181
1220
|
if (acct == null) return fail();
|
|
1182
|
-
return Response.bytes(new DataWriter().writeU8(acct.twoFactorMethod).toBytes());
|
|
1221
|
+
return Response.bytes(new DataWriter().writeU8(FactorRepository.read(h, u.username, acct.twoFactorMethod).method).toBytes());
|
|
1183
1222
|
}
|
|
1184
1223
|
|
|
1185
1224
|
/**
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@data
|
|
2
|
+
class FactorKey {
|
|
3
|
+
host: string = '';
|
|
4
|
+
username: string = '';
|
|
5
|
+
constructor(host: string = '', username: string = '') { this.host = host; this.username = username; }
|
|
6
|
+
}
|
|
7
|
+
@data
|
|
8
|
+
class FactorRevision {
|
|
9
|
+
host: string = '';
|
|
10
|
+
username: string = '';
|
|
11
|
+
revision: u64 = 0;
|
|
12
|
+
constructor(host: string = '', username: string = '', revision: u64 = 0) { this.host = host; this.username = username; this.revision = revision; }
|
|
13
|
+
}
|
|
14
|
+
@data
|
|
15
|
+
export class FactorSettings {
|
|
16
|
+
method: u8 = 0;
|
|
17
|
+
revision: u64 = 0;
|
|
18
|
+
}
|
|
19
|
+
@database
|
|
20
|
+
class FactorDb {
|
|
21
|
+
@collection static heads: Documents<FactorKey, FactorSettings>;
|
|
22
|
+
@collection static revisions: Unique<FactorRevision, FactorSettings>;
|
|
23
|
+
}
|
|
24
|
+
export class FactorRepository {
|
|
25
|
+
public static read(host: string, username: string, legacy: u8): FactorSettings {
|
|
26
|
+
let settings = FactorDb.heads.get(new FactorKey(host, username));
|
|
27
|
+
if (settings == null) { settings = new FactorSettings(); settings.method = legacy; }
|
|
28
|
+
for (let i: i32 = 0; i < 64; i++) {
|
|
29
|
+
const next = FactorDb.revisions.lookup(new FactorRevision(host, username, settings.revision + 1));
|
|
30
|
+
if (next == null) return settings;
|
|
31
|
+
settings = next;
|
|
32
|
+
}
|
|
33
|
+
throw new Error('Two-factor settings unavailable');
|
|
34
|
+
}
|
|
35
|
+
public static save(host: string, username: string, method: u8, expected: u64): bool {
|
|
36
|
+
const settings = new FactorSettings(); settings.method = method; settings.revision = expected + 1;
|
|
37
|
+
if (!FactorDb.revisions.claim(new FactorRevision(host, username, settings.revision), settings).claimed) return false;
|
|
38
|
+
FactorDb.heads.upsert(new FactorKey(host, username), settings);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DataReader } from 'data';
|
|
2
|
+
@data
|
|
3
|
+
class SessionUserKey {
|
|
4
|
+
id: string = '';
|
|
5
|
+
constructor(id: string = '') { this.id = id; }
|
|
6
|
+
}
|
|
7
|
+
@data
|
|
8
|
+
class SessionEpochKey {
|
|
9
|
+
id: string = '';
|
|
10
|
+
revision: u64 = 0;
|
|
11
|
+
constructor(id: string = '', revision: u64 = 0) { this.id = id; this.revision = revision; }
|
|
12
|
+
}
|
|
13
|
+
@data
|
|
14
|
+
class SessionEpoch {
|
|
15
|
+
revision: u64 = 0;
|
|
16
|
+
}
|
|
17
|
+
@database
|
|
18
|
+
class SessionEpochDb {
|
|
19
|
+
@collection static heads: Documents<SessionUserKey, SessionEpoch>;
|
|
20
|
+
@collection static revisions: Unique<SessionEpochKey, SessionEpoch>;
|
|
21
|
+
}
|
|
22
|
+
/** Immutable per-user generations fence sessions and in-flight login challenges without clock assumptions. */
|
|
23
|
+
export class SessionEpochs {
|
|
24
|
+
private static key(userData: Uint8Array): string {
|
|
25
|
+
const reader = new DataReader(userData);
|
|
26
|
+
reader.readU32(); // @data message-boundary identifier
|
|
27
|
+
const id = reader.readBytes();
|
|
28
|
+
if (!reader.ok || id.length != 32) throw new Error('Invalid session identity');
|
|
29
|
+
return crypto.toHex(id);
|
|
30
|
+
}
|
|
31
|
+
private static read(id: string): SessionEpoch {
|
|
32
|
+
let epoch = SessionEpochDb.heads.get(new SessionUserKey(id));
|
|
33
|
+
if (epoch == null) epoch = new SessionEpoch();
|
|
34
|
+
for (let i: i32 = 0; i < 64; i++) {
|
|
35
|
+
const next = SessionEpochDb.revisions.lookup(new SessionEpochKey(id, epoch.revision + 1));
|
|
36
|
+
if (next == null) return epoch;
|
|
37
|
+
epoch = next;
|
|
38
|
+
}
|
|
39
|
+
throw new Error('Session state unavailable');
|
|
40
|
+
}
|
|
41
|
+
public static current(userData: Uint8Array): u64 {
|
|
42
|
+
return SessionEpochs.read(SessionEpochs.key(userData)).revision;
|
|
43
|
+
}
|
|
44
|
+
public static allowed(userData: Uint8Array, generation: u64): bool {
|
|
45
|
+
return generation == SessionEpochs.current(userData);
|
|
46
|
+
}
|
|
47
|
+
public static revoke(userData: Uint8Array): void {
|
|
48
|
+
const id = SessionEpochs.key(userData);
|
|
49
|
+
for (let i: i32 = 0; i < 3; i++) {
|
|
50
|
+
const epoch = SessionEpochs.read(id);
|
|
51
|
+
epoch.revision += 1;
|
|
52
|
+
if (!SessionEpochDb.revisions.claim(new SessionEpochKey(id, epoch.revision), epoch).claimed) continue;
|
|
53
|
+
SessionEpochDb.heads.upsert(new SessionUserKey(id), epoch);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
throw new Error('Session revocation conflicted; retry');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
@data
|
|
2
|
+
export class SessionDigest {
|
|
3
|
+
digest: string = '';
|
|
4
|
+
constructor(digest: string = '') { this.digest = digest; }
|
|
5
|
+
}
|
|
6
|
+
@data
|
|
7
|
+
export class RevokedSession {
|
|
8
|
+
revoked: bool = true;
|
|
9
|
+
}
|
|
10
|
+
@database
|
|
11
|
+
export class SessionRevocations {
|
|
12
|
+
@collection static sessions: Unique<SessionDigest, RevokedSession>;
|
|
13
|
+
}
|
package/server/globals/auth.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// and the toiljs dev-server mock).
|
|
11
11
|
|
|
12
12
|
import { DataWriter, DataReader } from 'data';
|
|
13
|
+
import { SessionEpochs } from 'toiljs/server/auth/SessionEpochs';
|
|
14
|
+
import { SessionRevocations, SessionDigest, RevokedSession } from 'toiljs/server/auth/SessionRevocations';
|
|
13
15
|
import { HmacImportParams, HmacParams, ALG_SHA_256, FMT_RAW, USAGE_SIGN, USAGE_VERIFY } from 'crypto';
|
|
14
16
|
|
|
15
17
|
import {
|
|
@@ -260,7 +262,7 @@ export namespace AuthService {
|
|
|
260
262
|
}
|
|
261
263
|
|
|
262
264
|
/** Session payload format version (first byte of the sealed payload). */
|
|
263
|
-
const SESSION_VERSION: u8 =
|
|
265
|
+
const SESSION_VERSION: u8 = 2;
|
|
264
266
|
|
|
265
267
|
/** Default session lifetime if `mintSession` is called without a ttl. */
|
|
266
268
|
export const DEFAULT_SESSION_TTL_SECS: u64 = 86400; // 24h
|
|
@@ -290,6 +292,7 @@ export namespace AuthService {
|
|
|
290
292
|
sessionCookieName(__reqIsSecure()),
|
|
291
293
|
);
|
|
292
294
|
if (sealed == null) return null;
|
|
295
|
+
if (SessionRevocations.sessions.lookup(new SessionDigest(crypto.toHex(crypto.sha256Text(sealed)))) != null) return null;
|
|
293
296
|
|
|
294
297
|
const payload = base64UrlDecode(sealed);
|
|
295
298
|
if (payload == null) return null;
|
|
@@ -299,9 +302,13 @@ export namespace AuthService {
|
|
|
299
302
|
r.readU64(); // iat (unused on read)
|
|
300
303
|
const exp = r.readU64();
|
|
301
304
|
const userBytes = r.readBytes();
|
|
305
|
+
const nonce = r.readBytes();
|
|
306
|
+
const generation = r.readU64();
|
|
307
|
+
if (nonce.length != 32) return null;
|
|
302
308
|
if (!r.ok) return null; // truncated/malformed
|
|
303
309
|
|
|
304
310
|
if (Time.nowSeconds() >= exp) return null; // expired
|
|
311
|
+
if (!SessionEpochs.allowed(userBytes, generation)) return null;
|
|
305
312
|
|
|
306
313
|
return userBytes;
|
|
307
314
|
}
|
|
@@ -367,13 +374,17 @@ export namespace AuthService {
|
|
|
367
374
|
* `Response.setCookie(...)`. HMAC-signed, HttpOnly, Secure, SameSite=Lax,
|
|
368
375
|
* `__Host-` scoped. The value stays readable but cannot be forged or moved.
|
|
369
376
|
*/
|
|
370
|
-
export function mintSession(userData: Uint8Array, ttlSecs: u64 = DEFAULT_SESSION_TTL_SECS): Cookie {
|
|
377
|
+
export function mintSession(userData: Uint8Array, ttlSecs: u64 = DEFAULT_SESSION_TTL_SECS, generation: u64 = u64.MAX_VALUE): Cookie {
|
|
371
378
|
const now = Time.nowSeconds();
|
|
372
379
|
const w = new DataWriter();
|
|
373
380
|
w.writeU8(SESSION_VERSION);
|
|
374
381
|
w.writeU64(now);
|
|
375
382
|
w.writeU64(now + ttlSecs);
|
|
376
383
|
w.writeBytes(userData);
|
|
384
|
+
const nonce = new Uint8Array(32);
|
|
385
|
+
crypto.getRandomValues(nonce);
|
|
386
|
+
w.writeBytes(nonce);
|
|
387
|
+
w.writeU64(generation != u64.MAX_VALUE ? generation : SessionEpochs.current(userData));
|
|
377
388
|
|
|
378
389
|
const secure = __reqIsSecure();
|
|
379
390
|
let cookie = Cookie.create(SESSION_BASE, base64UrlEncode(w.toBytes()))
|
|
@@ -384,6 +395,20 @@ export namespace AuthService {
|
|
|
384
395
|
return SecureCookies.signed(__resolveSessionSecret()).seal(cookie);
|
|
385
396
|
}
|
|
386
397
|
|
|
398
|
+
/** Invalidate all browser sessions authorized under the prior session generation. */
|
|
399
|
+
export function revokeUserSessions(userData: Uint8Array): void {
|
|
400
|
+
SessionEpochs.revoke(userData);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Revoke the verified signed payload, including its unpredictable per-login nonce. */
|
|
404
|
+
export function revokeSession(): void {
|
|
405
|
+
if (!hasSession()) return;
|
|
406
|
+
const req = Server.currentRequest!;
|
|
407
|
+
const sealed = SecureCookies.signed(__resolveSessionSecret()).open(req.cookies(), sessionCookieName(__reqIsSecure()));
|
|
408
|
+
if (sealed == null) return;
|
|
409
|
+
SessionRevocations.sessions.claim(new SessionDigest(crypto.toHex(crypto.sha256Text(sealed))), new RevokedSession());
|
|
410
|
+
}
|
|
411
|
+
|
|
387
412
|
/** A `Set-Cookie` that immediately clears the session (logout). */
|
|
388
413
|
export function clearSession(): Cookie {
|
|
389
414
|
const secure = __reqIsSecure();
|