@technomoron/api-server-base 2.0.0-beta.1 → 2.0.0-beta.10

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.
Files changed (52) hide show
  1. package/README.txt +25 -2
  2. package/dist/cjs/api-server-base.cjs +269 -39
  3. package/dist/cjs/api-server-base.d.ts +27 -7
  4. package/dist/cjs/auth-api/auth-module.d.ts +11 -2
  5. package/dist/cjs/auth-api/auth-module.js +193 -45
  6. package/dist/cjs/auth-api/compat-auth-storage.d.ts +7 -5
  7. package/dist/cjs/auth-api/compat-auth-storage.js +15 -3
  8. package/dist/cjs/auth-api/mem-auth-store.d.ts +5 -3
  9. package/dist/cjs/auth-api/mem-auth-store.js +7 -1
  10. package/dist/cjs/auth-api/sql-auth-store.d.ts +5 -3
  11. package/dist/cjs/auth-api/sql-auth-store.js +7 -1
  12. package/dist/cjs/auth-api/storage.d.ts +6 -4
  13. package/dist/cjs/auth-api/storage.js +15 -5
  14. package/dist/cjs/auth-api/types.d.ts +7 -2
  15. package/dist/cjs/index.cjs +4 -4
  16. package/dist/cjs/index.d.ts +4 -4
  17. package/dist/cjs/oauth/sequelize.js +1 -1
  18. package/dist/cjs/passkey/base.d.ts +1 -0
  19. package/dist/cjs/passkey/memory.d.ts +1 -0
  20. package/dist/cjs/passkey/memory.js +4 -0
  21. package/dist/cjs/passkey/sequelize.d.ts +1 -0
  22. package/dist/cjs/passkey/sequelize.js +11 -2
  23. package/dist/cjs/passkey/service.d.ts +5 -2
  24. package/dist/cjs/passkey/service.js +145 -10
  25. package/dist/cjs/passkey/types.d.ts +3 -0
  26. package/dist/cjs/user/base.js +2 -1
  27. package/dist/esm/api-server-base.d.ts +27 -7
  28. package/dist/esm/api-server-base.js +270 -40
  29. package/dist/esm/auth-api/auth-module.d.ts +11 -2
  30. package/dist/esm/auth-api/auth-module.js +194 -46
  31. package/dist/esm/auth-api/compat-auth-storage.d.ts +7 -5
  32. package/dist/esm/auth-api/compat-auth-storage.js +13 -1
  33. package/dist/esm/auth-api/mem-auth-store.d.ts +5 -3
  34. package/dist/esm/auth-api/mem-auth-store.js +8 -2
  35. package/dist/esm/auth-api/sql-auth-store.d.ts +5 -3
  36. package/dist/esm/auth-api/sql-auth-store.js +8 -2
  37. package/dist/esm/auth-api/storage.d.ts +6 -4
  38. package/dist/esm/auth-api/storage.js +13 -3
  39. package/dist/esm/auth-api/types.d.ts +7 -2
  40. package/dist/esm/index.d.ts +4 -4
  41. package/dist/esm/index.js +2 -2
  42. package/dist/esm/oauth/sequelize.js +1 -1
  43. package/dist/esm/passkey/base.d.ts +1 -0
  44. package/dist/esm/passkey/memory.d.ts +1 -0
  45. package/dist/esm/passkey/memory.js +4 -0
  46. package/dist/esm/passkey/sequelize.d.ts +1 -0
  47. package/dist/esm/passkey/sequelize.js +11 -2
  48. package/dist/esm/passkey/service.d.ts +5 -2
  49. package/dist/esm/passkey/service.js +113 -11
  50. package/dist/esm/passkey/types.d.ts +3 -0
  51. package/dist/esm/user/base.js +2 -1
  52. package/package.json +3 -1
@@ -10,7 +10,7 @@ import cors from 'cors';
10
10
  import express from 'express';
11
11
  import multer from 'multer';
12
12
  import { nullAuthModule } from './auth-api/module.js';
13
- import { nullAuthStorage } from './auth-api/storage.js';
13
+ import { nullAuthAdapter } from './auth-api/storage.js';
14
14
  import { TokenStore } from './token/base.js';
15
15
  class JwtHelperStore extends TokenStore {
16
16
  async save() {
@@ -344,6 +344,7 @@ function fillConfig(config) {
344
344
  devMode: config.devMode ?? false,
345
345
  hydrateGetBody: config.hydrateGetBody ?? true,
346
346
  validateTokens: config.validateTokens ?? false,
347
+ refreshMaybe: config.refreshMaybe ?? false,
347
348
  apiVersion: config.apiVersion ?? '',
348
349
  minClientVersion: config.minClientVersion ?? '',
349
350
  tokenStore: config.tokenStore,
@@ -362,7 +363,7 @@ export class ApiServer {
362
363
  this.config = fillConfig(config);
363
364
  this.apiBasePath = this.normalizeApiBasePath(this.config.apiBasePath);
364
365
  this.startedAt = Date.now();
365
- this.storageAdapter = nullAuthStorage;
366
+ this.storageAdapter = nullAuthAdapter;
366
367
  this.moduleAdapter = nullAuthModule;
367
368
  this.jwtHelper = new JwtHelperStore();
368
369
  this.tokenStoreAdapter = this.config.tokenStore ?? null;
@@ -440,13 +441,19 @@ export class ApiServer {
440
441
  }
441
442
  return this.passkeyServiceAdapter;
442
443
  }
444
+ async listUserCredentials(userId) {
445
+ return this.ensurePasskeyService().listUserCredentials(userId);
446
+ }
447
+ async deletePasskeyCredential(credentialId) {
448
+ return this.ensurePasskeyService().deleteCredential(credentialId);
449
+ }
443
450
  ensureOAuthStore() {
444
451
  if (!this.oauthStoreAdapter) {
445
452
  throw new Error('OAuth store is not configured');
446
453
  }
447
454
  return this.oauthStoreAdapter;
448
455
  }
449
- // AuthStorage-compatible helpers (used by AuthModule)
456
+ // AuthAdapter-compatible helpers (used by AuthModule)
450
457
  async getUser(identifier) {
451
458
  return this.userStoreAdapter ? this.userStoreAdapter.findUser(identifier) : null;
452
459
  }
@@ -692,16 +699,117 @@ export class ApiServer {
692
699
  }
693
700
  async verifyJWT(token) {
694
701
  if (!this.config.accessSecret) {
695
- return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set' };
702
+ return { tokenData: undefined, error: 'JWT authentication disabled; no jwtSecret set', expired: false };
696
703
  }
697
704
  const result = this.jwtVerify(token, this.config.accessSecret);
698
705
  if (!result.success) {
699
- return { tokenData: undefined, error: result.error };
706
+ return { tokenData: undefined, error: result.error, expired: result.expired };
700
707
  }
701
708
  if (!result.data.uid) {
702
- return { tokenData: undefined, error: 'Missing/bad userid in token' };
709
+ return { tokenData: undefined, error: 'Missing/bad userid in token', expired: false };
710
+ }
711
+ return { tokenData: result.data, error: undefined, expired: false };
712
+ }
713
+ jwtCookieOptions(apiReq) {
714
+ const conf = this.config;
715
+ const forwarded = apiReq.req.headers['x-forwarded-proto'];
716
+ const referer = apiReq.req.headers['origin'] ?? apiReq.req.headers['referer'];
717
+ const origin = typeof referer === 'string' ? referer : '';
718
+ const isHttps = forwarded === 'https' || apiReq.req.protocol === 'https';
719
+ const isLocalhost = origin.includes('localhost');
720
+ const options = {
721
+ httpOnly: true,
722
+ secure: true,
723
+ sameSite: 'strict',
724
+ domain: conf.cookieDomain || undefined,
725
+ path: '/',
726
+ maxAge: undefined
727
+ };
728
+ if (conf.devMode) {
729
+ options.secure = isHttps;
730
+ options.httpOnly = false;
731
+ options.sameSite = 'lax';
732
+ if (isLocalhost) {
733
+ options.domain = undefined;
734
+ }
703
735
  }
704
- return { tokenData: result.data, error: undefined };
736
+ return options;
737
+ }
738
+ setAccessCookie(apiReq, accessToken, sessionCookie) {
739
+ const conf = this.config;
740
+ const options = this.jwtCookieOptions(apiReq);
741
+ const accessMaxAge = Math.max(1, conf.accessExpiry) * 1000;
742
+ const accessOptions = sessionCookie ? options : { ...options, maxAge: accessMaxAge };
743
+ apiReq.res.cookie(conf.accessCookie, accessToken, accessOptions);
744
+ }
745
+ async tryRefreshAccessToken(apiReq) {
746
+ const conf = this.config;
747
+ if (!conf.refreshSecret || !conf.accessSecret) {
748
+ return null;
749
+ }
750
+ const rawRefresh = apiReq.req.cookies?.[conf.refreshCookie];
751
+ if (typeof rawRefresh !== 'string') {
752
+ return null;
753
+ }
754
+ const refreshToken = rawRefresh.trim();
755
+ if (!refreshToken) {
756
+ return null;
757
+ }
758
+ const verify = this.jwtVerify(refreshToken, conf.refreshSecret);
759
+ if (!verify.success || !verify.data) {
760
+ return null;
761
+ }
762
+ let stored = null;
763
+ try {
764
+ stored = await this.storageAdapter.getToken({ refreshToken });
765
+ }
766
+ catch {
767
+ return null;
768
+ }
769
+ if (!stored) {
770
+ return null;
771
+ }
772
+ const storedUid = String(stored.userId);
773
+ const verifyUid = verify.data.uid === undefined || verify.data.uid === null ? null : String(verify.data.uid);
774
+ if (verifyUid && verifyUid !== storedUid) {
775
+ return null;
776
+ }
777
+ const claims = verify.data;
778
+ const { exp: _exp, iat: _iat, nbf: _nbf, ...payload } = claims;
779
+ void _exp;
780
+ void _iat;
781
+ void _nbf;
782
+ // Ensure we never embed token secrets into refreshed access tokens.
783
+ delete payload.accessToken;
784
+ delete payload.refreshToken;
785
+ delete payload.userId;
786
+ delete payload.expires;
787
+ delete payload.issuedAt;
788
+ delete payload.lastSeenAt;
789
+ delete payload.status;
790
+ const access = this.jwtSign(payload, conf.accessSecret, conf.accessExpiry);
791
+ if (!access.success || !access.token) {
792
+ return null;
793
+ }
794
+ const updated = await this.updateToken({
795
+ refreshToken,
796
+ accessToken: access.token,
797
+ lastSeenAt: new Date()
798
+ });
799
+ if (!updated) {
800
+ return null;
801
+ }
802
+ this.setAccessCookie(apiReq, access.token, stored.sessionCookie ?? false);
803
+ if (apiReq.req.cookies) {
804
+ apiReq.req.cookies[conf.accessCookie] = access.token;
805
+ }
806
+ const verifiedAccess = await this.verifyJWT(access.token);
807
+ if (!verifiedAccess.tokenData) {
808
+ return null;
809
+ }
810
+ const refreshedStored = { ...stored, accessToken: access.token };
811
+ apiReq.authToken = refreshedStored;
812
+ return { token: access.token, tokenData: verifiedAccess.tokenData, stored: refreshedStored };
705
813
  }
706
814
  async authenticate(apiReq, authType) {
707
815
  if (authType === 'none') {
@@ -711,6 +819,7 @@ export class ApiServer {
711
819
  let token = null;
712
820
  const authHeader = apiReq.req.headers.authorization;
713
821
  const requiresAuthToken = this.requiresAuthToken(authType);
822
+ const allowRefresh = requiresAuthToken || (authType === 'maybe' && this.config.refreshMaybe);
714
823
  const apiKeyAuth = await this.tryAuthenticateApiKey(apiReq, authType, authHeader);
715
824
  if (apiKeyAuth) {
716
825
  return apiKeyAuth;
@@ -719,32 +828,84 @@ export class ApiServer {
719
828
  token = authHeader.slice(7).trim();
720
829
  }
721
830
  if (!token) {
722
- const access = apiReq.req.cookies?.dat;
831
+ const access = apiReq.req.cookies?.[this.config.accessCookie];
723
832
  if (access) {
724
833
  token = access;
725
834
  }
726
835
  }
836
+ let tokenData;
837
+ let error;
838
+ let expired = false;
727
839
  if (!token || token === null) {
728
- if (requiresAuthToken) {
729
- throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
840
+ if (authType === 'maybe') {
841
+ if (!this.config.refreshMaybe) {
842
+ return null;
843
+ }
844
+ const refreshed = await this.tryRefreshAccessToken(apiReq);
845
+ if (!refreshed) {
846
+ return null;
847
+ }
848
+ token = refreshed.token;
849
+ tokenData = refreshed.tokenData;
850
+ error = undefined;
851
+ expired = false;
852
+ }
853
+ else if (requiresAuthToken) {
854
+ const refreshed = await this.tryRefreshAccessToken(apiReq);
855
+ if (!refreshed) {
856
+ throw new ApiError({ code: 401, message: 'Authorization token is required (Bearer/cookie)' });
857
+ }
858
+ token = refreshed.token;
859
+ tokenData = refreshed.tokenData;
860
+ error = undefined;
861
+ expired = false;
730
862
  }
731
863
  }
732
864
  if (!token) {
733
- if (authType === 'maybe') {
734
- return null;
735
- }
736
- else {
737
- throw new ApiError({ code: 401, message: 'Unauthorized Access - requires authentication' });
865
+ throw new ApiError({ code: 401, message: 'Unauthorized Access - requires authentication' });
866
+ }
867
+ if (!tokenData) {
868
+ const verified = await this.verifyJWT(token);
869
+ tokenData = verified.tokenData;
870
+ error = verified.error;
871
+ expired = verified.expired ?? false;
872
+ }
873
+ if (!tokenData && allowRefresh && expired) {
874
+ const refreshed = await this.tryRefreshAccessToken(apiReq);
875
+ if (refreshed) {
876
+ token = refreshed.token;
877
+ tokenData = refreshed.tokenData;
878
+ error = undefined;
738
879
  }
739
880
  }
740
- const { tokenData, error } = await this.verifyJWT(token);
741
881
  if (!tokenData) {
742
882
  throw new ApiError({ code: 401, message: 'Unathorized Access - ' + error });
743
883
  }
744
884
  const effectiveUserId = this.extractTokenUserId(tokenData);
745
885
  apiReq.realUid = this.resolveRealUserId(tokenData, effectiveUserId);
746
886
  if (this.shouldValidateStoredToken(authType)) {
747
- await this.assertStoredAccessToken(apiReq, token, tokenData);
887
+ try {
888
+ await this.assertStoredAccessToken(apiReq, token, tokenData);
889
+ }
890
+ catch (error) {
891
+ if (allowRefresh &&
892
+ error instanceof ApiError &&
893
+ error.code === 401 &&
894
+ error.message === 'Authorization token is no longer valid') {
895
+ const refreshed = await this.tryRefreshAccessToken(apiReq);
896
+ if (!refreshed) {
897
+ throw error;
898
+ }
899
+ token = refreshed.token;
900
+ tokenData = refreshed.tokenData;
901
+ const refreshedUserId = this.extractTokenUserId(tokenData);
902
+ apiReq.realUid = this.resolveRealUserId(tokenData, refreshedUserId);
903
+ await this.assertStoredAccessToken(apiReq, token, tokenData);
904
+ }
905
+ else {
906
+ throw error;
907
+ }
908
+ }
748
909
  }
749
910
  apiReq.token = token;
750
911
  return tokenData;
@@ -786,6 +947,9 @@ export class ApiServer {
786
947
  }
787
948
  async assertStoredAccessToken(apiReq, token, tokenData) {
788
949
  const userId = String(this.extractTokenUserId(tokenData));
950
+ if (apiReq.authToken && apiReq.authToken.accessToken === token && String(apiReq.authToken.userId) === userId) {
951
+ return;
952
+ }
789
953
  const stored = await this.storageAdapter.getToken({
790
954
  accessToken: token,
791
955
  userId
@@ -825,32 +989,98 @@ export class ApiServer {
825
989
  }
826
990
  return rawReal;
827
991
  }
828
- handle_request(handler, auth) {
992
+ useExpress(pathOrHandler, ...handlers) {
993
+ if (typeof pathOrHandler === 'string') {
994
+ this.app.use(pathOrHandler, ...handlers);
995
+ }
996
+ else {
997
+ this.app.use(pathOrHandler, ...handlers);
998
+ }
999
+ this.ensureApiNotFoundOrdering();
1000
+ return this;
1001
+ }
1002
+ createApiRequest(req, res) {
1003
+ const apiReq = {
1004
+ server: this,
1005
+ req,
1006
+ res,
1007
+ token: '',
1008
+ tokenData: null,
1009
+ realUid: null,
1010
+ getClientInfo: () => ensureClientInfo(apiReq),
1011
+ getClientIp: () => ensureClientInfo(apiReq).ip,
1012
+ getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
1013
+ getRealUid: () => apiReq.realUid ?? null,
1014
+ isImpersonating: () => {
1015
+ const realUid = apiReq.realUid;
1016
+ const tokenUid = apiReq.tokenData?.uid;
1017
+ if (realUid === null || realUid === undefined) {
1018
+ return false;
1019
+ }
1020
+ if (tokenUid === null || tokenUid === undefined) {
1021
+ return false;
1022
+ }
1023
+ return realUid !== tokenUid;
1024
+ }
1025
+ };
1026
+ return apiReq;
1027
+ }
1028
+ expressAuth(auth) {
829
1029
  return async (req, res, next) => {
830
- void next;
831
- const apiReq = {
832
- server: this,
833
- req,
834
- res,
835
- token: '',
836
- tokenData: null,
837
- realUid: null,
838
- getClientInfo: () => ensureClientInfo(apiReq),
839
- getClientIp: () => ensureClientInfo(apiReq).ip,
840
- getClientIpChain: () => ensureClientInfo(apiReq).ipchain,
841
- getRealUid: () => apiReq.realUid ?? null,
842
- isImpersonating: () => {
843
- const realUid = apiReq.realUid;
844
- const tokenUid = apiReq.tokenData?.uid;
845
- if (realUid === null || realUid === undefined) {
846
- return false;
847
- }
848
- if (tokenUid === null || tokenUid === undefined) {
849
- return false;
850
- }
851
- return realUid !== tokenUid;
1030
+ const apiReq = this.createApiRequest(req, res);
1031
+ req.apiReq = apiReq;
1032
+ res.locals.apiReq = apiReq;
1033
+ this.currReq = apiReq;
1034
+ try {
1035
+ if (this.config.hydrateGetBody) {
1036
+ hydrateGetBody(req);
1037
+ }
1038
+ if (this.config.debug) {
1039
+ this.dumpRequest(apiReq);
852
1040
  }
1041
+ apiReq.tokenData = await this.authenticate(apiReq, auth.type);
1042
+ await this.authorize(apiReq, auth.req);
1043
+ next();
1044
+ }
1045
+ catch (error) {
1046
+ next(error);
1047
+ }
1048
+ };
1049
+ }
1050
+ expressErrorHandler() {
1051
+ return (error, _req, res, next) => {
1052
+ void _req;
1053
+ if (res.headersSent) {
1054
+ next(error);
1055
+ return;
1056
+ }
1057
+ if (error instanceof ApiError || isApiErrorLike(error)) {
1058
+ const apiError = error;
1059
+ const normalizedErrors = apiError.errors && typeof apiError.errors === 'object' && !Array.isArray(apiError.errors)
1060
+ ? apiError.errors
1061
+ : {};
1062
+ const errorPayload = {
1063
+ code: apiError.code,
1064
+ message: apiError.message,
1065
+ data: apiError.data ?? null,
1066
+ errors: normalizedErrors
1067
+ };
1068
+ res.status(apiError.code).json(errorPayload);
1069
+ return;
1070
+ }
1071
+ const errorPayload = {
1072
+ code: 500,
1073
+ message: this.guessExceptionText(error),
1074
+ data: null,
1075
+ errors: {}
853
1076
  };
1077
+ res.status(500).json(errorPayload);
1078
+ };
1079
+ }
1080
+ handle_request(handler, auth) {
1081
+ return async (req, res, next) => {
1082
+ void next;
1083
+ const apiReq = this.createApiRequest(req, res);
854
1084
  this.currReq = apiReq;
855
1085
  try {
856
1086
  if (this.config.hydrateGetBody) {
@@ -1,6 +1,6 @@
1
1
  import { type ApiRequest, type ApiRoute, type ApiServer } from '../api-server-base.js';
2
2
  import { BaseAuthModule, type AuthProviderModule } from './module.js';
3
- import type { AuthIdentifier, AuthStorage } from './types.js';
3
+ import type { AuthAdapter, AuthIdentifier } from './types.js';
4
4
  import type { OAuthCallbackParams, OAuthCallbackResult, OAuthStartParams, OAuthStartResult } from '../oauth/types.js';
5
5
  import type { TokenPair, Token } from '../token/types.js';
6
6
  interface CanImpersonateContext<UserEntity> {
@@ -46,7 +46,7 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
46
46
  private readonly defaultDomain?;
47
47
  private readonly canImpersonateHook?;
48
48
  constructor(options?: AuthModuleOptions<UserEntity>);
49
- protected get storage(): AuthStorage<UserEntity, PublicUser>;
49
+ protected get storage(): AuthAdapter<UserEntity, PublicUser>;
50
50
  protected canImpersonate(apiReq: ApiRequest, realUser: UserEntity, targetUser: UserEntity): Promise<boolean>;
51
51
  protected ensureImpersonationAllowed(apiReq: ApiRequest, realUser: UserEntity, targetUser: UserEntity): Promise<void>;
52
52
  protected buildTokenPayload(user: UserEntity, metadata?: TokenMetadata): TokenClaims;
@@ -57,6 +57,9 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
57
57
  private resolveSessionPreferences;
58
58
  private mergeSessionPreferences;
59
59
  private sessionPrefsFromRecord;
60
+ private validateCredentialId;
61
+ private normalizeCredentialId;
62
+ private toIsoDate;
60
63
  private cookieOptions;
61
64
  private setJwtCookies;
62
65
  issueTokens(apiReq: ApiRequest, user: UserEntity, metadata?: TokenIssueOptions): Promise<TokenPair>;
@@ -76,6 +79,8 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
76
79
  private postWhoAmI;
77
80
  private postPasskeyChallenge;
78
81
  private postPasskeyVerify;
82
+ private getPasskeys;
83
+ private deletePasskey;
79
84
  private postImpersonation;
80
85
  private deleteImpersonation;
81
86
  private getUserFromPasskey;
@@ -91,6 +96,10 @@ export default class AuthModule<UserEntity, PublicUser> extends BaseAuthModule<U
91
96
  private resolveClientAuthentication;
92
97
  private assertRedirectUriAllowed;
93
98
  private resolveUserForOAuth;
99
+ private hasPasskeyService;
100
+ private hasOAuthStore;
101
+ private storageImplements;
102
+ private storageImplementsAll;
94
103
  defineRoutes(): ApiRoute[];
95
104
  }
96
105
  export {};