Created structure for web-app's js client library for consuming onyx api.
This commit is contained in:
		
							parent
							
								
									321bad46a7
								
							
						
					
					
						commit
						c7bf9d9058
					
				|  | @ -0,0 +1,6 @@ | ||||||
|  | package com.andrewlalis.onyx.auth.api; | ||||||
|  | 
 | ||||||
|  | public record AccessTokenResponse( | ||||||
|  |         String accessToken, | ||||||
|  |         long expiresAt | ||||||
|  | ) {} | ||||||
|  | @ -20,12 +20,17 @@ public class AuthController { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @GetMapping("/auth/access") |     @GetMapping("/auth/access") | ||||||
|     public Object getAccessToken(HttpServletRequest request) { |     public AccessTokenResponse getAccessToken(HttpServletRequest request) { | ||||||
|         return Map.of("accessToken", tokenService.generateAccessToken(request)); |         return tokenService.generateAccessToken(request); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @DeleteMapping("/auth/refresh-tokens") |     @DeleteMapping("/auth/refresh-tokens") | ||||||
|     public void removeAllRefreshTokens(@AuthenticationPrincipal User user) { |     public void removeAllRefreshTokens(@AuthenticationPrincipal User user) { | ||||||
|         tokenService.removeAllRefreshTokens(user); |         tokenService.removeAllRefreshTokens(user); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     @GetMapping("/auth/token-expiration") | ||||||
|  |     public Object getTokenExpiration(HttpServletRequest request) { | ||||||
|  |         return Map.of("expiresAt", tokenService.getTokenExpiration(request)); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,3 +1,8 @@ | ||||||
| package com.andrewlalis.onyx.auth.api; | package com.andrewlalis.onyx.auth.api; | ||||||
| 
 | 
 | ||||||
| public record TokenPair(String refreshToken, String accessToken) {} | public record TokenPair( | ||||||
|  |         String refreshToken, | ||||||
|  |         long refreshTokenExpiresAt, | ||||||
|  |         String accessToken, | ||||||
|  |         long accessTokenExpiresAt | ||||||
|  | ) {} | ||||||
|  |  | ||||||
|  | @ -1,5 +1,6 @@ | ||||||
| package com.andrewlalis.onyx.auth.service; | package com.andrewlalis.onyx.auth.service; | ||||||
| 
 | 
 | ||||||
|  | import com.andrewlalis.onyx.auth.api.AccessTokenResponse; | ||||||
| import com.andrewlalis.onyx.auth.api.LoginRequest; | import com.andrewlalis.onyx.auth.api.LoginRequest; | ||||||
| import com.andrewlalis.onyx.auth.api.TokenPair; | import com.andrewlalis.onyx.auth.api.TokenPair; | ||||||
| import com.andrewlalis.onyx.auth.model.RefreshToken; | import com.andrewlalis.onyx.auth.model.RefreshToken; | ||||||
|  | @ -14,7 +15,6 @@ import jakarta.servlet.http.HttpServletRequest; | ||||||
| import lombok.RequiredArgsConstructor; | import lombok.RequiredArgsConstructor; | ||||||
| import lombok.extern.slf4j.Slf4j; | import lombok.extern.slf4j.Slf4j; | ||||||
| import org.springframework.http.HttpStatus; | import org.springframework.http.HttpStatus; | ||||||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; |  | ||||||
| import org.springframework.security.crypto.password.PasswordEncoder; | import org.springframework.security.crypto.password.PasswordEncoder; | ||||||
| import org.springframework.stereotype.Service; | import org.springframework.stereotype.Service; | ||||||
| import org.springframework.transaction.annotation.Transactional; | import org.springframework.transaction.annotation.Transactional; | ||||||
|  | @ -29,6 +29,7 @@ import java.time.Instant; | ||||||
| import java.time.LocalDateTime; | import java.time.LocalDateTime; | ||||||
| import java.time.OffsetDateTime; | import java.time.OffsetDateTime; | ||||||
| import java.time.ZoneOffset; | import java.time.ZoneOffset; | ||||||
|  | import java.time.temporal.ChronoUnit; | ||||||
| import java.util.Date; | import java.util.Date; | ||||||
| import java.util.Optional; | import java.util.Optional; | ||||||
| 
 | 
 | ||||||
|  | @ -38,6 +39,8 @@ import java.util.Optional; | ||||||
| public class TokenService { | public class TokenService { | ||||||
|     private static final String BEARER_PREFIX = "Bearer "; |     private static final String BEARER_PREFIX = "Bearer "; | ||||||
|     private static final String ISSUER = "Onyx API"; |     private static final String ISSUER = "Onyx API"; | ||||||
|  |     private static final int REFRESH_TOKEN_EXPIRATION_DAYS = 30; | ||||||
|  |     private static final int ACCESS_TOKEN_EXPIRATION_MINUTES = 120; | ||||||
| 
 | 
 | ||||||
|     private PrivateKey signingKey; |     private PrivateKey signingKey; | ||||||
|     private final PasswordEncoder passwordEncoder; |     private final PasswordEncoder passwordEncoder; | ||||||
|  | @ -54,14 +57,16 @@ public class TokenService { | ||||||
|             throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); |             throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials."); | ||||||
|         } |         } | ||||||
|         User user = optionalUser.get(); |         User user = optionalUser.get(); | ||||||
|         String refreshToken = generateRefreshToken(user); |         final Instant now = OffsetDateTime.now(ZoneOffset.UTC).toInstant(); | ||||||
|         String accessToken = generateAccessToken(refreshToken); |         Instant refreshTokenExpiration = now.plus(REFRESH_TOKEN_EXPIRATION_DAYS, ChronoUnit.DAYS); | ||||||
|         return new TokenPair(refreshToken, accessToken); |         String refreshToken = generateRefreshToken(user, refreshTokenExpiration); | ||||||
|  |         Instant accessTokenExpiration = now.plus(ACCESS_TOKEN_EXPIRATION_MINUTES, ChronoUnit.MINUTES); | ||||||
|  |         String accessToken = generateAccessToken(refreshToken, accessTokenExpiration); | ||||||
|  |         return new TokenPair(refreshToken, refreshTokenExpiration.toEpochMilli(), accessToken, accessTokenExpiration.toEpochMilli()); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Transactional |     @Transactional | ||||||
|     public String generateRefreshToken(User user) { |     public String generateRefreshToken(User user, Instant expiresAt) { | ||||||
|         Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusDays(7).toInstant(); |  | ||||||
|         try { |         try { | ||||||
|             String token = Jwts.builder() |             String token = Jwts.builder() | ||||||
|                     .setSubject(Long.toString(user.getId())) |                     .setSubject(Long.toString(user.getId())) | ||||||
|  | @ -86,7 +91,7 @@ public class TokenService { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Transactional(readOnly = true) |     @Transactional(readOnly = true) | ||||||
|     public String generateAccessToken(String refreshTokenString) { |     public String generateAccessToken(String refreshTokenString, Instant expiresAt) { | ||||||
|         String suffix = refreshTokenString.substring(refreshTokenString.length() - RefreshToken.SUFFIX_LENGTH); |         String suffix = refreshTokenString.substring(refreshTokenString.length() - RefreshToken.SUFFIX_LENGTH); | ||||||
|         RefreshToken refreshToken = null; |         RefreshToken refreshToken = null; | ||||||
|         for (RefreshToken possibleToken : refreshTokenRepository.findAllByTokenSuffix(suffix)) { |         for (RefreshToken possibleToken : refreshTokenRepository.findAllByTokenSuffix(suffix)) { | ||||||
|  | @ -101,7 +106,6 @@ public class TokenService { | ||||||
|         if (refreshToken.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) { |         if (refreshToken.getExpiresAt().isBefore(LocalDateTime.now(ZoneOffset.UTC))) { | ||||||
|             throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token is expired."); |             throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Refresh token is expired."); | ||||||
|         } |         } | ||||||
|         Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusHours(1).toInstant(); |  | ||||||
|         try { |         try { | ||||||
|             return Jwts.builder() |             return Jwts.builder() | ||||||
|                     .setSubject(Long.toString(refreshToken.getUser().getId())) |                     .setSubject(Long.toString(refreshToken.getUser().getId())) | ||||||
|  | @ -119,12 +123,16 @@ public class TokenService { | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Transactional(readOnly = true) |     @Transactional(readOnly = true) | ||||||
|     public String generateAccessToken(HttpServletRequest request) { |     public AccessTokenResponse generateAccessToken(HttpServletRequest request) { | ||||||
|         String refreshTokenString = extractBearerToken(request); |         String refreshTokenString = extractBearerToken(request); | ||||||
|         if (refreshTokenString == null) { |         if (refreshTokenString == null) { | ||||||
|             throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing refresh token."); |             throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Missing refresh token."); | ||||||
|         } |         } | ||||||
|         return generateAccessToken(refreshTokenString); |         Instant expiresAt = OffsetDateTime.now(ZoneOffset.UTC).plusMinutes(ACCESS_TOKEN_EXPIRATION_MINUTES).toInstant(); | ||||||
|  |         return new AccessTokenResponse( | ||||||
|  |                 generateAccessToken(refreshTokenString, expiresAt), | ||||||
|  |                 expiresAt.toEpochMilli() | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @Transactional |     @Transactional | ||||||
|  | @ -132,6 +140,16 @@ public class TokenService { | ||||||
|         refreshTokenRepository.deleteAllByUserId(user.getId()); |         refreshTokenRepository.deleteAllByUserId(user.getId()); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     public long getTokenExpiration(HttpServletRequest request) { | ||||||
|  |         try { | ||||||
|  |             Jws<Claims> jws = getToken(request); | ||||||
|  |             return jws.getBody().getExpiration().getTime(); | ||||||
|  |         } catch (Exception e) { | ||||||
|  |             log.warn("Exception occurred while getting token expiration.", e); | ||||||
|  |             return -1; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     public Jws<Claims> getToken(HttpServletRequest request) throws Exception { |     public Jws<Claims> getToken(HttpServletRequest request) throws Exception { | ||||||
|         String rawToken = extractBearerToken(request); |         String rawToken = extractBearerToken(request); | ||||||
|         if (rawToken == null) return null; |         if (rawToken == null) return null; | ||||||
|  |  | ||||||
|  | @ -27,6 +27,7 @@ public class SecurityConfig { | ||||||
|         http.authorizeHttpRequests(registry -> { |         http.authorizeHttpRequests(registry -> { | ||||||
|             registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/auth/login")).permitAll(); |             registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.POST, "/auth/login")).permitAll(); | ||||||
|             registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/auth/access")).permitAll(); |             registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/auth/access")).permitAll(); | ||||||
|  |             registry.requestMatchers(AntPathRequestMatcher.antMatcher(HttpMethod.GET, "/auth/token-expiration")).permitAll(); | ||||||
|             registry.anyRequest().authenticated(); |             registry.anyRequest().authenticated(); | ||||||
|         }); |         }); | ||||||
|         http.csrf(AbstractHttpConfigurer::disable); |         http.csrf(AbstractHttpConfigurer::disable); | ||||||
|  |  | ||||||
|  | @ -8,6 +8,7 @@ | ||||||
|       "name": "onyx-web-app", |       "name": "onyx-web-app", | ||||||
|       "version": "0.0.0", |       "version": "0.0.0", | ||||||
|       "dependencies": { |       "dependencies": { | ||||||
|  |         "axios": "^1.6.0", | ||||||
|         "pinia": "^2.1.7", |         "pinia": "^2.1.7", | ||||||
|         "vue": "^3.3.4", |         "vue": "^3.3.4", | ||||||
|         "vue-router": "^4.2.5" |         "vue-router": "^4.2.5" | ||||||
|  | @ -1241,6 +1242,21 @@ | ||||||
|         "node": ">=8" |         "node": ">=8" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/asynckit": { | ||||||
|  |       "version": "0.4.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", | ||||||
|  |       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" | ||||||
|  |     }, | ||||||
|  |     "node_modules/axios": { | ||||||
|  |       "version": "1.6.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.0.tgz", | ||||||
|  |       "integrity": "sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==", | ||||||
|  |       "dependencies": { | ||||||
|  |         "follow-redirects": "^1.15.0", | ||||||
|  |         "form-data": "^4.0.0", | ||||||
|  |         "proxy-from-env": "^1.1.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/balanced-match": { |     "node_modules/balanced-match": { | ||||||
|       "version": "1.0.2", |       "version": "1.0.2", | ||||||
|       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", |       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", | ||||||
|  | @ -1318,6 +1334,17 @@ | ||||||
|       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", |       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", | ||||||
|       "dev": true |       "dev": true | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/combined-stream": { | ||||||
|  |       "version": "1.0.8", | ||||||
|  |       "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", | ||||||
|  |       "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", | ||||||
|  |       "dependencies": { | ||||||
|  |         "delayed-stream": "~1.0.0" | ||||||
|  |       }, | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">= 0.8" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/computeds": { |     "node_modules/computeds": { | ||||||
|       "version": "0.0.1", |       "version": "0.0.1", | ||||||
|       "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", |       "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", | ||||||
|  | @ -1390,6 +1417,14 @@ | ||||||
|       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", |       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", | ||||||
|       "dev": true |       "dev": true | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/delayed-stream": { | ||||||
|  |       "version": "1.0.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", | ||||||
|  |       "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">=0.4.0" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/dir-glob": { |     "node_modules/dir-glob": { | ||||||
|       "version": "3.0.1", |       "version": "3.0.1", | ||||||
|       "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", |       "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", | ||||||
|  | @ -1755,6 +1790,38 @@ | ||||||
|       "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", |       "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", | ||||||
|       "dev": true |       "dev": true | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/follow-redirects": { | ||||||
|  |       "version": "1.15.3", | ||||||
|  |       "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", | ||||||
|  |       "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", | ||||||
|  |       "funding": [ | ||||||
|  |         { | ||||||
|  |           "type": "individual", | ||||||
|  |           "url": "https://github.com/sponsors/RubenVerborgh" | ||||||
|  |         } | ||||||
|  |       ], | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">=4.0" | ||||||
|  |       }, | ||||||
|  |       "peerDependenciesMeta": { | ||||||
|  |         "debug": { | ||||||
|  |           "optional": true | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "node_modules/form-data": { | ||||||
|  |       "version": "4.0.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", | ||||||
|  |       "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", | ||||||
|  |       "dependencies": { | ||||||
|  |         "asynckit": "^0.4.0", | ||||||
|  |         "combined-stream": "^1.0.8", | ||||||
|  |         "mime-types": "^2.1.12" | ||||||
|  |       }, | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">= 6" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/fs.realpath": { |     "node_modules/fs.realpath": { | ||||||
|       "version": "1.0.0", |       "version": "1.0.0", | ||||||
|       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", |       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", | ||||||
|  | @ -2166,6 +2233,25 @@ | ||||||
|         "node": ">=8.6" |         "node": ">=8.6" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/mime-db": { | ||||||
|  |       "version": "1.52.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", | ||||||
|  |       "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">= 0.6" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "node_modules/mime-types": { | ||||||
|  |       "version": "2.1.35", | ||||||
|  |       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", | ||||||
|  |       "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", | ||||||
|  |       "dependencies": { | ||||||
|  |         "mime-db": "1.52.0" | ||||||
|  |       }, | ||||||
|  |       "engines": { | ||||||
|  |         "node": ">= 0.6" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     "node_modules/minimatch": { |     "node_modules/minimatch": { | ||||||
|       "version": "3.1.2", |       "version": "3.1.2", | ||||||
|       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", |       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", | ||||||
|  | @ -2564,6 +2650,11 @@ | ||||||
|         "node": ">= 0.8.0" |         "node": ">= 0.8.0" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     "node_modules/proxy-from-env": { | ||||||
|  |       "version": "1.1.0", | ||||||
|  |       "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", | ||||||
|  |       "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" | ||||||
|  |     }, | ||||||
|     "node_modules/punycode": { |     "node_modules/punycode": { | ||||||
|       "version": "2.3.0", |       "version": "2.3.0", | ||||||
|       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", |       "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", | ||||||
|  |  | ||||||
|  | @ -11,6 +11,7 @@ | ||||||
|     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" |     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|  |     "axios": "^1.6.0", | ||||||
|     "pinia": "^2.1.7", |     "pinia": "^2.1.7", | ||||||
|     "vue": "^3.3.4", |     "vue": "^3.3.4", | ||||||
|     "vue-router": "^4.2.5" |     "vue-router": "^4.2.5" | ||||||
|  |  | ||||||
|  | @ -0,0 +1,48 @@ | ||||||
|  | import type {OnyxApi} from "@/api-client/onyx-api"; | ||||||
|  | 
 | ||||||
|  | export interface LoginInfo { | ||||||
|  |   username: string; | ||||||
|  |   password: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface TokenData { | ||||||
|  |   token: string; | ||||||
|  |   expiresAt: number; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | interface TokenPair { | ||||||
|  |   accessToken: TokenData; | ||||||
|  |   refreshToken: TokenData; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * Authentication endpoints in the Onyx API. | ||||||
|  |  */ | ||||||
|  | export class OnyxAuthApi { | ||||||
|  |   constructor(readonly api: OnyxApi) {} | ||||||
|  | 
 | ||||||
|  |   async login(loginInfo: LoginInfo): Promise<TokenPair> { | ||||||
|  |     const data: any = await this.api.request({method: "POST", url: "/auth/login", data: loginInfo}, true) | ||||||
|  |     return { | ||||||
|  |       accessToken: {token: data.accessToken, expiresAt: data.accessTokenExpiresAt}, | ||||||
|  |       refreshToken: {token: data.refreshToken, expiresAt: data.refreshTokenExpiresAt} | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async getAccessToken(refreshToken: string): Promise<TokenData> { | ||||||
|  |     const data: any = await this.api.request({url: "/auth/access", headers: {"Authorization": "Bearer " + refreshToken}}); | ||||||
|  |     return { | ||||||
|  |       token: data.accessToken, | ||||||
|  |       expiresAt: data.expiresAt | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async getTokenExpiration(token: string): Promise<number> { | ||||||
|  |     const data: any = await this.api.request({url: "/auth/token-expiration", headers: {"Authorization": "Bearer " + token}}, true); | ||||||
|  |     return data.expiresAt; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async removeAllRefreshTokens(): Promise<void> { | ||||||
|  |     await this.api.request({method: "DELETE", url: "/auth/refresh-tokens"}) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,67 @@ | ||||||
|  | import type {OnyxApi} from "@/api-client/onyx-api"; | ||||||
|  | 
 | ||||||
|  | export interface ContentNode { | ||||||
|  |   id: number; | ||||||
|  |   name: string; | ||||||
|  |   nodeType: string; | ||||||
|  |   archived: boolean; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface ContainerChildData { | ||||||
|  |   id: number; | ||||||
|  |   name: string; | ||||||
|  |   nodeType: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface ContainerNode extends ContentNode { | ||||||
|  |   children: ContainerChildData[] | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface DocumentNode extends ContentNode { | ||||||
|  |   contentType: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface ContainerNodeCreationData { | ||||||
|  |   name: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export interface DocumentNodeCreationData { | ||||||
|  |   name: string; | ||||||
|  |   contentType: string; | ||||||
|  |   content: string; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * The API for interacting with nodes in an Onyx instance's content tree. | ||||||
|  |  */ | ||||||
|  | export class OnyxContentApi { | ||||||
|  |   constructor(readonly api: OnyxApi) {} | ||||||
|  | 
 | ||||||
|  |   async getNode(id: number): Promise<ContainerNode | DocumentNode> { | ||||||
|  |     return await this.api.request({url: "/content/nodes/" + id}); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async getRoot(): Promise<ContainerNode> { | ||||||
|  |     return await this.api.request({url: "/content/nodes/root"}); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async createContainer(parentNodeId: number, data: ContainerNodeCreationData): Promise<ContainerNode> { | ||||||
|  |     return await this.api.request({ | ||||||
|  |       method: "POST", | ||||||
|  |       url: "/content/nodes/" + parentNodeId + "/children", | ||||||
|  |       data: data | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async createDocument(parentNodeId: number, data: DocumentNodeCreationData): Promise<DocumentNode> { | ||||||
|  |     return await this.api.request({ | ||||||
|  |       method: "POST", | ||||||
|  |       url: "/content/nodes/" + parentNodeId + "/children", | ||||||
|  |       data: data | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async deleteNode(id: number): Promise<void> { | ||||||
|  |     await this.api.request({method: "DELETE", url: "/content/nodes/" + id}); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -0,0 +1,142 @@ | ||||||
|  | import type {AxiosRequestConfig} from "axios"; | ||||||
|  | import axios, {Axios} from "axios"; | ||||||
|  | import {OnyxAuthApi} from "@/api-client/onyx-api-auth"; | ||||||
|  | import type{LoginInfo, TokenData} from "@/api-client/onyx-api-auth"; | ||||||
|  | import {OnyxContentApi} from "@/api-client/onyx-api-content"; | ||||||
|  | 
 | ||||||
|  | interface OnyxAuthState { | ||||||
|  |   accessToken: TokenData | null; | ||||||
|  |   refreshToken: TokenData | null; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export class OnyxApi { | ||||||
|  |   private authState: OnyxAuthState = {accessToken: null, refreshToken: null}; | ||||||
|  |   private accessTokenIntervalId: number | null = null; | ||||||
|  |   private httpClient: Axios; | ||||||
|  | 
 | ||||||
|  |   readonly auth: OnyxAuthApi = new OnyxAuthApi(this); | ||||||
|  |   readonly content: OnyxContentApi = new OnyxContentApi(this); | ||||||
|  | 
 | ||||||
|  |   constructor() { | ||||||
|  |     this.httpClient = axios.create({ | ||||||
|  |       baseURL: "http://localhost:8080/", | ||||||
|  |       timeout: 3000 | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Loads a new API instance from information in the user's local storage, | ||||||
|  |    * which should ideally have a stored refresh token and its expiration date. | ||||||
|  |    * Using this information, we can fetch a new access token and any other auth | ||||||
|  |    * data. Will return an API instance that's authenticated if we have the info | ||||||
|  |    * to do so, and all necessary services are available. Otherwise, an | ||||||
|  |    * unauthenticated instance is returned. | ||||||
|  |    */ | ||||||
|  |   static async loadFromLocalStorage(): Promise<OnyxApi> { | ||||||
|  |     const refreshToken = localStorage.getItem("onyx-api-refresh-token"); | ||||||
|  |     const expirationStr = localStorage.getItem("onyx-api-refresh-token-expiration"); | ||||||
|  |     let expiration = (expirationStr !== null) ? parseInt(expirationStr, 10): null; | ||||||
|  |     const api = new OnyxApi(); | ||||||
|  |     // If no expiration was stored, try and get that first.
 | ||||||
|  |     if (refreshToken && expiration === null) { | ||||||
|  |       try { | ||||||
|  |         expiration = await api.auth.getTokenExpiration(refreshToken); | ||||||
|  |       } catch (error: any) { | ||||||
|  |         console.error(error); | ||||||
|  |         return api; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (expiration === null) return api; // This is to make the type-checker happy.
 | ||||||
|  |     // If we have a valid refresh token that's not expired, get an access token and init the auth state.
 | ||||||
|  |     if (refreshToken && expiration > Date.now()) { | ||||||
|  |       try { | ||||||
|  |         api.authState.accessToken = await api.auth.getAccessToken(refreshToken); | ||||||
|  |         api.authState.refreshToken = {token: refreshToken, expiresAt: expiration}; | ||||||
|  |         api.saveAuthStateToLocalStorage(); | ||||||
|  |       } catch (error: any) { | ||||||
|  |         console.error(error); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return api; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private saveAuthStateToLocalStorage() { | ||||||
|  |     if (this.authState.refreshToken) { | ||||||
|  |       localStorage.setItem("onyx-api-refresh-token", this.authState.refreshToken?.token); | ||||||
|  |       localStorage.setItem("onyx-api-refresh-token-expiration", this.authState.refreshToken.expiresAt.toString(10)); | ||||||
|  |     } else { | ||||||
|  |       localStorage.removeItem("onyx-api-refresh-token"); | ||||||
|  |       localStorage.removeItem("onyx-api-refresh-token-expiration"); | ||||||
|  |     } | ||||||
|  |     console.log("Saved current auth state to local storage.", this.authState.refreshToken); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   private initAccessTokenRefreshing() { | ||||||
|  |     if (this.accessTokenIntervalId) { | ||||||
|  |       window.clearInterval(this.accessTokenIntervalId); | ||||||
|  |     } | ||||||
|  |     this.accessTokenIntervalId = window.setInterval(async () => { | ||||||
|  |       if (this.authState.refreshToken && this.authState.accessToken && this.authState.accessToken.expiresAt < Date.now() + 5000) { | ||||||
|  |         this.authState.accessToken = await this.auth.getAccessToken(this.authState.refreshToken.token); | ||||||
|  |         this.saveAuthStateToLocalStorage(); | ||||||
|  |         console.log("Updated access token."); | ||||||
|  |       } | ||||||
|  |     }, 5000); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Attempts to do a login with the given credentials to obtain a new refresh token. | ||||||
|  |    * @param credentials The credentials to use. | ||||||
|  |    */ | ||||||
|  |   async login(credentials: LoginInfo): Promise<void> { | ||||||
|  |     const tokens = await this.auth.login(credentials); | ||||||
|  |     this.authState = { | ||||||
|  |       accessToken: tokens.accessToken, | ||||||
|  |       refreshToken: tokens.refreshToken | ||||||
|  |     }; | ||||||
|  |     this.saveAuthStateToLocalStorage(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Logs this instance out if it was logged in. | ||||||
|  |    */ | ||||||
|  |   logout() { | ||||||
|  |     if (this.accessTokenIntervalId) { | ||||||
|  |       window.clearInterval(this.accessTokenIntervalId); | ||||||
|  |     } | ||||||
|  |     this.authState = {accessToken: null, refreshToken: null}; | ||||||
|  |     this.saveAuthStateToLocalStorage(); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Makes a request to the Onyx API, setting the Authorization header when | ||||||
|  |    * this API instance is authenticated. | ||||||
|  |    * @param config The axios request object. | ||||||
|  |    * @param anon Whether to make the request anonymous (no auth). | ||||||
|  |    */ | ||||||
|  |   async request<R>(config: AxiosRequestConfig, anon: boolean = false): Promise<R> { | ||||||
|  |     if (!config.headers) config.headers = {}; | ||||||
|  |     if (!anon && this.isAuthenticated()) { | ||||||
|  |       config.headers["Authorization"] = "Bearer " + this.authState.accessToken?.token; | ||||||
|  |     } | ||||||
|  |     if (config.data) { | ||||||
|  |       config.headers["Content-Type"] = "application/json"; | ||||||
|  |     } | ||||||
|  |     const response = await this.httpClient.request(config); | ||||||
|  |     if (response.status === 200) { | ||||||
|  |       return response.data; | ||||||
|  |     } | ||||||
|  |     throw response.status; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   /** | ||||||
|  |    * Tells whether this API instance is authenticated and likely able to | ||||||
|  |    * access endpoints that require authentication. | ||||||
|  |    */ | ||||||
|  |   isAuthenticated(): boolean { | ||||||
|  |     return this.authState.accessToken !== null && | ||||||
|  |       this.authState.accessToken.expiresAt > Date.now() && | ||||||
|  |       this.authState.refreshToken !== null && | ||||||
|  |       this.authState.refreshToken.expiresAt > Date.now(); | ||||||
|  |   } | ||||||
|  | } | ||||||
		Loading…
	
		Reference in New Issue