From e4dd20b1198f4332aadd92ce91716227e8915976 Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 29 Dec 2025 12:51:53 -0700 Subject: [PATCH 01/34] startup now requires a client-id for oauth --- src/main/java/Main.java | 4 ++++ .../java/edu/byu/cs/properties/ApplicationProperties.java | 1 + 2 files changed, 5 insertions(+) diff --git a/src/main/java/Main.java b/src/main/java/Main.java index 2b6a43dcf..70fe7b8b3 100644 --- a/src/main/java/Main.java +++ b/src/main/java/Main.java @@ -90,6 +90,9 @@ private static void setupProperties(String[] args) { if (cmd.hasOption("disable-compilation")) { properties.setProperty("run-compilation", "false"); } + if(cmd.hasOption("client-id")){ + properties.setProperty("client-id", cmd.getOptionValue("client-id")); + } } catch (ParseException e) { throw new RuntimeException("Error parsing command line arguments", e); } @@ -109,6 +112,7 @@ private static Options getOptions() { options.addOption(null, "canvas-token", true, "Canvas Token"); options.addOption(null, "use-canvas", true, "Using Canvas"); options.addOption(null, "disable-compilation", false, "Turn off student code compilation"); + options.addOption(null, "client-id", true, "Client ID for BYU OAuth"); return options; } diff --git a/src/main/java/edu/byu/cs/properties/ApplicationProperties.java b/src/main/java/edu/byu/cs/properties/ApplicationProperties.java index 9f972a890..8b9f4016d 100644 --- a/src/main/java/edu/byu/cs/properties/ApplicationProperties.java +++ b/src/main/java/edu/byu/cs/properties/ApplicationProperties.java @@ -58,6 +58,7 @@ public static String frontendUrl() { return mustGet("frontend-url"); } + public static String clientId() {return mustGet("client-id");} public static String casCallbackUrl() { return mustGet("cas-callback-url"); From e96329c402573bdabda92b0388608d2a4a5ac0fd Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 31 Dec 2025 00:01:12 +0000 Subject: [PATCH 02/34] successfully retrieves an api token however the authentication still will not work --- .../edu/byu/cs/controller/CasController.java | 157 ++++----- .../java/edu/byu/cs/service/CasService.java | 306 +++++++++++------- 2 files changed, 277 insertions(+), 186 deletions(-) diff --git a/src/main/java/edu/byu/cs/controller/CasController.java b/src/main/java/edu/byu/cs/controller/CasController.java index 8ec60c15e..0ee0508a6 100644 --- a/src/main/java/edu/byu/cs/controller/CasController.java +++ b/src/main/java/edu/byu/cs/controller/CasController.java @@ -1,72 +1,85 @@ -package edu.byu.cs.controller; - -import edu.byu.cs.canvas.CanvasException; -import edu.byu.cs.dataAccess.DataAccessException; -import edu.byu.cs.model.User; -import edu.byu.cs.properties.ApplicationProperties; -import edu.byu.cs.service.CasService; -import edu.byu.cs.service.ConfigService; -import io.javalin.http.Context; -import io.javalin.http.Handler; -import io.javalin.http.HttpStatus; - -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - -import static edu.byu.cs.util.JwtUtils.generateToken; - -/** - * Handles CAS-related HTTP endpoints. CAS, standing for Central Authentication Service, - * is BYU's centralized authentication provider for all BYU users - */ -public class CasController { - public static final Handler callbackGet = ctx -> { - String ticket = ctx.queryParam("ticket"); - - User user; - try { - user = CasService.callback(ticket); - } catch (CanvasException e) { - String errorUrlParam = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8); - ctx.redirect(ApplicationProperties.frontendUrl() + "/login?error=" + errorUrlParam, HttpStatus.FOUND); - return; - } - - // FIXME: secure cookie with httpOnly - ctx.cookie("token", generateToken(user.netId()), 14400); - - redirect(ctx); - }; - - public static final Handler loginGet = ctx -> { - // check if already logged in - if (ctx.cookie("token") != null) { - redirect(ctx); - return; - } - ctx.redirect(CasService.BYU_CAS_URL + "/login" + "?service=" + ApplicationProperties.casCallbackUrl()); - }; - - - private static void redirect(Context ctx) throws DataAccessException { - String redirectTo; - if(ctx.sessionAttribute("slack") != null) { - redirectTo = ConfigService.getSlackLink(); - ctx.sessionAttribute("slack", null); - } - else redirectTo = ApplicationProperties.frontendUrl(); - ctx.redirect(redirectTo, HttpStatus.FOUND); - } - - public static final Handler logoutPost = ctx -> { - if (ctx.cookie("token") == null) { - ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.UNAUTHORIZED); - return; - } - - // TODO: call cas logout endpoint with ticket - ctx.removeCookie("token", "/"); - ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.OK); - }; - -} +package edu.byu.cs.controller; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import edu.byu.cs.canvas.CanvasException; +import edu.byu.cs.dataAccess.DataAccessException; +import edu.byu.cs.model.User; +import edu.byu.cs.properties.ApplicationProperties; +import edu.byu.cs.service.CasService; +import edu.byu.cs.service.ConfigService; +import static edu.byu.cs.util.JwtUtils.generateToken; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.HttpStatus; + +/** + * Handles CAS-related HTTP endpoints. CAS, standing for Central Authentication Service, + * is BYU's centralized authentication provider for all BYU users + */ +public class CasController { + //FIXME: when in production mode should be api-production + public static final String BYU_OAUTH_URL = "https://api-sandbox.byu.edu"; + + public static final Handler callbackGet = ctx -> { + System.out.println("Callback called"); + String code = ctx.queryParam("code"); + System.out.println("code extracted"); + //TODO: throw a fit if there's no code + CasService.TokenResponse response = CasService.exchangeCodeForTokens(code); + System.out.println("Token recieved"); + System.out.println(CasService.callPersonApi(response.accessToken())); + String ticket = ctx.queryParam("ticket"); + + User user; + try { + user = CasService.callback(ticket); + } catch (CanvasException e) { + String errorUrlParam = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8); + ctx.redirect(ApplicationProperties.frontendUrl() + "/login?error=" + errorUrlParam, HttpStatus.FOUND); + return; + } + + // FIXME: secure cookie with httpOnly + ctx.cookie("token", generateToken(user.netId()), 14400); + + redirect(ctx); + }; + + + + public static final Handler loginGet = ctx -> { + // check if already logged in + if (ctx.cookie("token") != null) { + redirect(ctx); + return; + } + //ctx.redirect(CasService.BYU_CAS_URL + "/login" + "?service=" + ApplicationProperties.casCallbackUrl()); + ctx.redirect(BYU_OAUTH_URL + "/authorize" + "?response_type=code" + "&client_id="+ + ApplicationProperties.clientId() + "&redirect_uri=" + ApplicationProperties.casCallbackUrl()); + }; + + + private static void redirect(Context ctx) throws DataAccessException { + String redirectTo; + if(ctx.sessionAttribute("slack") != null) { + redirectTo = ConfigService.getSlackLink(); + ctx.sessionAttribute("slack", null); + } + else redirectTo = ApplicationProperties.frontendUrl(); + ctx.redirect(redirectTo, HttpStatus.FOUND); + } + + public static final Handler logoutPost = ctx -> { + if (ctx.cookie("token") == null) { + ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.UNAUTHORIZED); + return; + } + + // TODO: call cas logout endpoint with ticket + ctx.removeCookie("token", "/"); + ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.OK); + }; + +} diff --git a/src/main/java/edu/byu/cs/service/CasService.java b/src/main/java/edu/byu/cs/service/CasService.java index 2a91fa598..8a10ed8a2 100644 --- a/src/main/java/edu/byu/cs/service/CasService.java +++ b/src/main/java/edu/byu/cs/service/CasService.java @@ -1,114 +1,192 @@ -package edu.byu.cs.service; - -import com.fasterxml.jackson.dataformat.xml.XmlMapper; -import edu.byu.cs.canvas.CanvasException; -import edu.byu.cs.canvas.CanvasService; -import edu.byu.cs.controller.exception.BadRequestException; -import edu.byu.cs.controller.exception.InternalServerException; -import edu.byu.cs.dataAccess.DaoService; -import edu.byu.cs.dataAccess.DataAccessException; -import edu.byu.cs.dataAccess.daoInterface.UserDao; -import edu.byu.cs.model.User; -import edu.byu.cs.properties.ApplicationProperties; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import javax.net.ssl.HttpsURLConnection; -import java.io.IOException; -import java.net.URI; -import java.util.Map; - -/** - * Contains service logic for the {@link edu.byu.cs.controller.CasController}.
View the - * Berkeley CAS docs - * to understand how CAS, or Central Authentication Service, works, if needed. - *

- * The {@code CasService} ensures user authentication using BYU's CAS before they access - * and use the AutoGrader. - */ -public class CasService { - private static final Logger LOGGER = LoggerFactory.getLogger(CasService.class); - public static final String BYU_CAS_URL = "https://cas.byu.edu/cas"; - - /** - * Validates a CAS ticket and retrieves the associated user. - *
- * If the user exists in the database, they are returned directly. Otherwise, the user - * is retrieved from Canvas and stored in the database before being returned - * - * @param ticket the CAS ticket to validate - * @return the user, either stored in the database or from Canvas if not - * @throws InternalServerException if an error arose during ticket validation or user retrieval - * @throws BadRequestException if ticket validation failed - * @throws DataAccessException if there was an issue storing the user in the database - * @throws CanvasException if there was an issue getting the user from Canvas - */ - public static User callback(String ticket) throws InternalServerException, BadRequestException, DataAccessException, CanvasException { - String netId; - try { - netId = CasService.validateCasTicket(ticket); - } catch (IOException e) { - LOGGER.error("Error validating ticket", e); - throw new InternalServerException("Error validating ticket", e); - } - - if (netId == null) { - throw new BadRequestException("Ticket validation failed"); - } - - UserDao userDao = DaoService.getUserDao(); - - User user; - // Check if student is already in the database - try { - user = userDao.getUser(netId); - } catch (DataAccessException e) { - LOGGER.error("Couldn't get user from database", e); - throw new InternalServerException("Couldn't get user from database", e); - } - - // If there isn't a student in the database with this netId - if (user == null) { - try { - user = CanvasService.getCanvasIntegration().getUser(netId); - } catch (CanvasException e) { - LOGGER.error("Error getting user from canvas", e); - throw e; - } - - userDao.insertUser(user); - LOGGER.info("Registered {}", user); - } - return user; - } - - /** - * Validates a CAS ticket and returns the netId of the user if valid
- * Berkeley CAS docs - * - * @param ticket the ticket to validate - * @return the netId of the user if valid, null otherwise - * @throws IOException if there is an error with the CAS server response - */ - public static String validateCasTicket(String ticket) throws IOException { - String validationUrl = BYU_CAS_URL + "/serviceValidate" + "?ticket=" + ticket + "&service=" + ApplicationProperties.casCallbackUrl(); - - - URI uri = URI.create(validationUrl); - HttpsURLConnection connection = (HttpsURLConnection) uri.toURL().openConnection(); - - try { - String body = new String(connection.getInputStream().readAllBytes()); - - Map casServiceResponse = XmlMapper.builder().build().readValue(body, Map.class); - return (String) ((Map) casServiceResponse.get("authenticationSuccess")).get("user"); - - } catch (Exception e) { - LOGGER.error("Error with response from CAS server:", e); - throw e; - } finally { - connection.disconnect(); - } - } - -} +package edu.byu.cs.service; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import javax.net.ssl.HttpsURLConnection; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; + +import edu.byu.cs.canvas.CanvasException; +import edu.byu.cs.canvas.CanvasService; +import edu.byu.cs.controller.exception.BadRequestException; +import edu.byu.cs.controller.exception.InternalServerException; +import edu.byu.cs.dataAccess.DaoService; +import edu.byu.cs.dataAccess.DataAccessException; +import edu.byu.cs.dataAccess.daoInterface.UserDao; +import edu.byu.cs.model.User; +import edu.byu.cs.properties.ApplicationProperties; + +/** + * Contains service logic for the {@link edu.byu.cs.controller.CasController}.
View the + * Berkeley CAS docs + * to understand how CAS, or Central Authentication Service, works, if needed. + *

+ * The {@code CasService} ensures user authentication using BYU's CAS before they access + * and use the AutoGrader. + */ +public class CasService { + public static final String BYU_CAS_URL = "https://cas.byu.edu/cas"; + public static final String BYU_OAUTH_URL = "https://api-sandbox.byu.edu"; + private static final Logger LOGGER = LoggerFactory.getLogger(CasService.class); + + private static final HttpClient httpClient = HttpClient.newHttpClient(); + + /** + * Validates a CAS ticket and retrieves the associated user. + *
+ * If the user exists in the database, they are returned directly. Otherwise, the user + * is retrieved from Canvas and stored in the database before being returned + * + * @param ticket the CAS ticket to validate + * @return the user, either stored in the database or from Canvas if not + * @throws InternalServerException if an error arose during ticket validation or user retrieval + * @throws BadRequestException if ticket validation failed + * @throws DataAccessException if there was an issue storing the user in the database + * @throws CanvasException if there was an issue getting the user from Canvas + */ + public static User callback(String ticket) throws InternalServerException, BadRequestException, DataAccessException, CanvasException { + String netId; + try { + netId = CasService.validateCasTicket(ticket); + } catch (IOException e) { + LOGGER.error("Error validating ticket", e); + throw new InternalServerException("Error validating ticket", e); + } + + if (netId == null) { + throw new BadRequestException("Ticket validation failed"); + } + + UserDao userDao = DaoService.getUserDao(); + + User user; + // Check if student is already in the database + try { + user = userDao.getUser(netId); + } catch (DataAccessException e) { + LOGGER.error("Couldn't get user from database", e); + throw new InternalServerException("Couldn't get user from database", e); + } + + // If there isn't a student in the database with this netId + if (user == null) { + try { + user = CanvasService.getCanvasIntegration().getUser(netId); + } catch (CanvasException e) { + LOGGER.error("Error getting user from canvas", e); + throw e; + } + + userDao.insertUser(user); + LOGGER.info("Registered {}", user); + } + return user; + } + + /** + * Validates a CAS ticket and returns the netId of the user if valid
+ * Berkeley CAS docs + * + * @param ticket the ticket to validate + * @return the netId of the user if valid, null otherwise + * @throws IOException if there is an error with the CAS server response + */ + public static String validateCasTicket(String ticket) throws IOException { + String validationUrl = BYU_CAS_URL + "/serviceValidate" + "?ticket=" + ticket + "&service=" + ApplicationProperties.casCallbackUrl(); + + + URI uri = URI.create(validationUrl); + HttpsURLConnection connection = (HttpsURLConnection) uri.toURL().openConnection(); + + try { + String body = new String(connection.getInputStream().readAllBytes()); + + Map casServiceResponse = XmlMapper.builder().build().readValue(body, Map.class); + return (String) ((Map) casServiceResponse.get("authenticationSuccess")).get("user"); + + } catch (Exception e) { + LOGGER.error("Error with response from CAS server:", e); + throw e; + } finally { + connection.disconnect(); + } + } + + public static TokenResponse exchangeCodeForTokens(String code) throws IOException, InterruptedException { + + String tokenUrl = BYU_OAUTH_URL + "/token"; + + + String formData = "grant_type=authorization_code" + + "&client_id=" + URLEncoder.encode(ApplicationProperties.clientId(), StandardCharsets.UTF_8) + + // "&client_secret=" + URLEncoder.encode(CognitoAuthProperties.APP_CLIENT_SECRET, StandardCharsets.UTF_8) + + "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + + "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8); + + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(formData)) + .build(); + + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + + + return getTokenResponse(response.body()); + + } + + private static TokenResponse getTokenResponse(String response) { + + return new Gson().fromJson(response, TokenResponse.class); + + } + + public record TokenResponse( + @SerializedName("access_token") String accessToken, + @SerializedName("id_token") String idToken, + @SerializedName("refresh_token") String refreshToken, + @SerializedName("expires_in") int expiresIn, + @SerializedName("token_type") String tokenType + + ) {} + + public static String callPersonApi(String token) throws InternalServerException{ + String personUrl = BYU_OAUTH_URL + "api/tbd/"; + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(personUrl)) + .header("Accept", "application/json") + .header("Authorization", "Bearer " + token) + .method("GET", HttpRequest.BodyPublishers.noBody()) + .build(); + try{ + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 200 && response.statusCode() < 300){ + return response.body(); + } + LOGGER.error("Persons API did not respond with a successful code:{}", response.statusCode()); + return response.body(); + } catch (IOException | InterruptedException e) { + LOGGER.error("Could not contact BYU Persons API:{}", e.getMessage()); + throw new InternalServerException("Could not verify identity", e); + } + } + +} From a645adb4d110cc1e9fc93dc9941ff22a7e276fae Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 5 Jan 2026 17:40:40 -0700 Subject: [PATCH 03/34] add dummy to docker compose for client id --- compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/compose.yml b/compose.yml index 46dd78eca..75f24ed38 100644 --- a/compose.yml +++ b/compose.yml @@ -31,6 +31,7 @@ services: "--canvas-token", "changeme", "--use-canvas", "true", # "--disable-compilation", # Enable me, if desired! + "--client-id", "changeme", ] networks: - autograder From dab83ee6bc2a3b52d35537266030ee44e3abcf94 Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 6 Jan 2026 15:53:57 -0700 Subject: [PATCH 04/34] pull openid data from the source and read the jwt token for identity --- .../edu/byu/cs/controller/CasController.java | 15 +- .../java/edu/byu/cs/service/CasService.java | 153 ++++++++++++++---- src/main/java/edu/byu/cs/util/JwtUtils.java | 27 ++++ 3 files changed, 156 insertions(+), 39 deletions(-) diff --git a/src/main/java/edu/byu/cs/controller/CasController.java b/src/main/java/edu/byu/cs/controller/CasController.java index 0ee0508a6..2fbdc3b76 100644 --- a/src/main/java/edu/byu/cs/controller/CasController.java +++ b/src/main/java/edu/byu/cs/controller/CasController.java @@ -19,22 +19,17 @@ * is BYU's centralized authentication provider for all BYU users */ public class CasController { - //FIXME: when in production mode should be api-production - public static final String BYU_OAUTH_URL = "https://api-sandbox.byu.edu"; public static final Handler callbackGet = ctx -> { - System.out.println("Callback called"); String code = ctx.queryParam("code"); - System.out.println("code extracted"); //TODO: throw a fit if there's no code CasService.TokenResponse response = CasService.exchangeCodeForTokens(code); - System.out.println("Token recieved"); - System.out.println(CasService.callPersonApi(response.accessToken())); - String ticket = ctx.queryParam("ticket"); + + //String ticket = ctx.queryParam("ticket"); User user; try { - user = CasService.callback(ticket); + user = CasService.callback(response.idToken()); } catch (CanvasException e) { String errorUrlParam = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8); ctx.redirect(ApplicationProperties.frontendUrl() + "/login?error=" + errorUrlParam, HttpStatus.FOUND); @@ -55,9 +50,7 @@ public class CasController { redirect(ctx); return; } - //ctx.redirect(CasService.BYU_CAS_URL + "/login" + "?service=" + ApplicationProperties.casCallbackUrl()); - ctx.redirect(BYU_OAUTH_URL + "/authorize" + "?response_type=code" + "&client_id="+ - ApplicationProperties.clientId() + "&redirect_uri=" + ApplicationProperties.casCallbackUrl()); + ctx.redirect(CasService.getAuthorizationUrl()); }; diff --git a/src/main/java/edu/byu/cs/service/CasService.java b/src/main/java/edu/byu/cs/service/CasService.java index 8a10ed8a2..1f5129201 100644 --- a/src/main/java/edu/byu/cs/service/CasService.java +++ b/src/main/java/edu/byu/cs/service/CasService.java @@ -7,10 +7,17 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collection; import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import javax.management.RuntimeErrorException; import javax.net.ssl.HttpsURLConnection; +import edu.byu.cs.util.JwtUtils; +import io.jsonwebtoken.security.JwkSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,10 +45,15 @@ */ public class CasService { public static final String BYU_CAS_URL = "https://cas.byu.edu/cas"; - public static final String BYU_OAUTH_URL = "https://api-sandbox.byu.edu"; + public static final String BYU_API_URL = "https://api-sandbox.byu.edu"; private static final Logger LOGGER = LoggerFactory.getLogger(CasService.class); private static final HttpClient httpClient = HttpClient.newHttpClient(); + private static Instant configExpiration = Instant.now(); + private static Instant keyExpiration = Instant.now(); + + public static openIDConfig config; + private static JwkSet byuPublicKeys; /** * Validates a CAS ticket and retrieves the associated user. @@ -59,8 +71,8 @@ public class CasService { public static User callback(String ticket) throws InternalServerException, BadRequestException, DataAccessException, CanvasException { String netId; try { - netId = CasService.validateCasTicket(ticket); - } catch (IOException e) { + netId = CasService.validateToken(ticket); + } catch (IOException | InterruptedException e) { LOGGER.error("Error validating ticket", e); throw new InternalServerException("Error validating ticket", e); } @@ -124,20 +136,29 @@ public static String validateCasTicket(String ticket) throws IOException { } } - public static TokenResponse exchangeCodeForTokens(String code) throws IOException, InterruptedException { + public static String validateToken(String token) throws InternalServerException, IOException, InterruptedException { + if (isExpired(keyExpiration)){ + if (isExpired(configExpiration)){ + cacheBYUOpenIDConfig(); + } + cacheJWK(); - String tokenUrl = BYU_OAUTH_URL + "/token"; + } + return JwtUtils.validateToken(token, byuPublicKeys); + } + public static TokenResponse exchangeCodeForTokens(String code) throws IOException, InterruptedException, InternalServerException { + if (isExpired(configExpiration)){ + cacheBYUOpenIDConfig(); + } String formData = "grant_type=authorization_code" + "&client_id=" + URLEncoder.encode(ApplicationProperties.clientId(), StandardCharsets.UTF_8) + - // "&client_secret=" + URLEncoder.encode(CognitoAuthProperties.APP_CLIENT_SECRET, StandardCharsets.UTF_8) + "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(tokenUrl)) + .uri(URI.create(config.tokenEndpoint)) .header("Content-Type", "application/x-www-form-urlencoded") .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(formData)) @@ -146,17 +167,12 @@ public static TokenResponse exchangeCodeForTokens(String code) throws IOExceptio HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + LOGGER.info(response.body()); - - return getTokenResponse(response.body()); + return new Gson().fromJson(response.body(), TokenResponse.class); } - private static TokenResponse getTokenResponse(String response) { - - return new Gson().fromJson(response, TokenResponse.class); - - } public record TokenResponse( @SerializedName("access_token") String accessToken, @@ -167,26 +183,107 @@ public record TokenResponse( ) {} - public static String callPersonApi(String token) throws InternalServerException{ - String personUrl = BYU_OAUTH_URL + "api/tbd/"; + + /** + * Is only called once to get the inital OpenIDConfig + */ + public static void initalizeCache() { + try{ + cacheBYUOpenIDConfig(); + cacheJWK(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static void cacheBYUOpenIDConfig() throws InternalServerException { HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(personUrl)) + .uri(URI.create(BYU_API_URL + "/.well-known/openid-configuration")) .header("Accept", "application/json") - .header("Authorization", "Bearer " + token) - .method("GET", HttpRequest.BodyPublishers.noBody()) + .GET() .build(); try{ - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() >= 200 && response.statusCode() < 300){ - return response.body(); + + configExpiration = getCacheTime(response); + + openIDConfig config = new Gson().fromJson(response.body(), openIDConfig.class); + if (isValidConfig(config)){ + CasService.config = config; } - LOGGER.error("Persons API did not respond with a successful code:{}", response.statusCode()); - return response.body(); - } catch (IOException | InterruptedException e) { - LOGGER.error("Could not contact BYU Persons API:{}", e.getMessage()); - throw new InternalServerException("Could not verify identity", e); + else { + throw new Exception("Invalid OpenIDConfig"); + } + + } catch (Exception e){ + LOGGER.error("Unable to pull openid config from BYU:", e); + throw new InternalServerException("Unable to determine identity", e); + } + } + + private static void cacheJWK () throws IOException, InterruptedException, InternalServerException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.keyUri)) + .header("Accept", "application/json") + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + keyExpiration = getCacheTime(response); + + byuPublicKeys = JwtUtils.readJWKs(response.body()); + + } + + private static Instant getCacheTime(HttpResponse response) throws InternalServerException { + Optional cache = response.headers().firstValue("Cache-Control"); + try{ + String seconds = cache.get().replace("max-age=", ""); + return Instant.now().plusSeconds(Long.parseLong(seconds)); + } + catch (NoSuchElementException e) { + throw new InternalServerException("Unable to determine cache time", e); } } + public record openIDConfig( + String issuer, + @SerializedName("authorization_endpoint") String authorizationEndpoint, + @SerializedName("token_endpoint") String tokenEndpoint, + @SerializedName("jwks_uri") String keyUri, + @SerializedName("scopes_supported") Collection scopes, + @SerializedName("id_token_signing_alg_values_supported")Collection encryptions + ){}; + + + private static boolean isValidConfig(openIDConfig config){ + if (!config.issuer().equals(BYU_API_URL)){ + return false; + } + if (!config.equals(CasService.config) && CasService.config != null){ + LOGGER.info("OpenID config has changed: {}", config); + } + if (config.scopes().size()!= 1){ + LOGGER.warn("Config has multiple scopes: {}", config); + } + if (config.encryptions().size()!=1){ + LOGGER.warn("Config has multiple encryption types: {}", config); + } + return config.authorizationEndpoint.contains(BYU_API_URL) && config.tokenEndpoint().contains(BYU_API_URL) && + config.keyUri().contains(BYU_API_URL); + } + + private static boolean isExpired(Instant time){ + return time.isBefore(Instant.now()); + } + + public static String getAuthorizationUrl() throws InternalServerException{ + if (isExpired(configExpiration)){ + cacheBYUOpenIDConfig(); + } + return CasService.config.authorizationEndpoint() + "?response_type=code" + "&client_id="+ + ApplicationProperties.clientId() + "&redirect_uri=" + ApplicationProperties.casCallbackUrl() + + "&scope=openid"; + } } diff --git a/src/main/java/edu/byu/cs/util/JwtUtils.java b/src/main/java/edu/byu/cs/util/JwtUtils.java index 55afae8c2..809773c7a 100644 --- a/src/main/java/edu/byu/cs/util/JwtUtils.java +++ b/src/main/java/edu/byu/cs/util/JwtUtils.java @@ -1,12 +1,15 @@ package edu.byu.cs.util; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.JwkSet; +import io.jsonwebtoken.security.Jwks; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -69,4 +72,28 @@ private static SecretKey generateSecretKey() { keyGenerator.init(512, new SecureRandom()); return keyGenerator.generateKey(); } + + public static String validateToken(String token, JwkSet keys){ + String netid = null; + if (keys.size() == 1){ + var key = keys.getKeys().stream().findFirst().get().toKey(); + try { + netid = Jwts.parser() + .verifyWith((PublicKey) key) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } catch (Exception e) { + LOGGER.error("Unable to verify with JWK:",e); + } + } + return netid; + } + + public static JwkSet readJWKs(String json){ + return Jwks.setParser() + .build() + .parse(json); + } } From 122ed07f46b708065fb138d65e5a8feee82a6b5f Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 6 Jan 2026 16:18:15 -0700 Subject: [PATCH 05/34] Rename CasController.java to RedirectController and updated documentation --- ...ontroller.java => RedirectController.java} | 159 +++++++++--------- .../EndpointProviderImpl.java | 6 +- .../java/edu/byu/cs/service/CasService.java | 4 +- 3 files changed, 86 insertions(+), 83 deletions(-) rename src/main/java/edu/byu/cs/controller/{CasController.java => RedirectController.java} (88%) diff --git a/src/main/java/edu/byu/cs/controller/CasController.java b/src/main/java/edu/byu/cs/controller/RedirectController.java similarity index 88% rename from src/main/java/edu/byu/cs/controller/CasController.java rename to src/main/java/edu/byu/cs/controller/RedirectController.java index 2fbdc3b76..a40e99656 100644 --- a/src/main/java/edu/byu/cs/controller/CasController.java +++ b/src/main/java/edu/byu/cs/controller/RedirectController.java @@ -1,78 +1,81 @@ -package edu.byu.cs.controller; - -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; - -import edu.byu.cs.canvas.CanvasException; -import edu.byu.cs.dataAccess.DataAccessException; -import edu.byu.cs.model.User; -import edu.byu.cs.properties.ApplicationProperties; -import edu.byu.cs.service.CasService; -import edu.byu.cs.service.ConfigService; -import static edu.byu.cs.util.JwtUtils.generateToken; -import io.javalin.http.Context; -import io.javalin.http.Handler; -import io.javalin.http.HttpStatus; - -/** - * Handles CAS-related HTTP endpoints. CAS, standing for Central Authentication Service, - * is BYU's centralized authentication provider for all BYU users - */ -public class CasController { - - public static final Handler callbackGet = ctx -> { - String code = ctx.queryParam("code"); - //TODO: throw a fit if there's no code - CasService.TokenResponse response = CasService.exchangeCodeForTokens(code); - - //String ticket = ctx.queryParam("ticket"); - - User user; - try { - user = CasService.callback(response.idToken()); - } catch (CanvasException e) { - String errorUrlParam = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8); - ctx.redirect(ApplicationProperties.frontendUrl() + "/login?error=" + errorUrlParam, HttpStatus.FOUND); - return; - } - - // FIXME: secure cookie with httpOnly - ctx.cookie("token", generateToken(user.netId()), 14400); - - redirect(ctx); - }; - - - - public static final Handler loginGet = ctx -> { - // check if already logged in - if (ctx.cookie("token") != null) { - redirect(ctx); - return; - } - ctx.redirect(CasService.getAuthorizationUrl()); - }; - - - private static void redirect(Context ctx) throws DataAccessException { - String redirectTo; - if(ctx.sessionAttribute("slack") != null) { - redirectTo = ConfigService.getSlackLink(); - ctx.sessionAttribute("slack", null); - } - else redirectTo = ApplicationProperties.frontendUrl(); - ctx.redirect(redirectTo, HttpStatus.FOUND); - } - - public static final Handler logoutPost = ctx -> { - if (ctx.cookie("token") == null) { - ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.UNAUTHORIZED); - return; - } - - // TODO: call cas logout endpoint with ticket - ctx.removeCookie("token", "/"); - ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.OK); - }; - -} +package edu.byu.cs.controller; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import edu.byu.cs.canvas.CanvasException; +import edu.byu.cs.dataAccess.DataAccessException; +import edu.byu.cs.model.User; +import edu.byu.cs.properties.ApplicationProperties; +import edu.byu.cs.service.CasService; +import edu.byu.cs.service.ConfigService; +import static edu.byu.cs.util.JwtUtils.generateToken; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import io.javalin.http.HttpStatus; + +/** + * Handles Redirect related endpoints, including the class chat link (i. e. Slack or Discord) + * and authentication redirects. + */ +public class RedirectController { + + public static final Handler callbackGet = ctx -> { + String code = ctx.queryParam("code"); + //TODO: throw a fit if there's no code + CasService.TokenResponse response = CasService.exchangeCodeForTokens(code); + + //String ticket = ctx.queryParam("ticket"); + + User user; + try { + user = CasService.callback(response.idToken()); + } catch (CanvasException e) { + String errorUrlParam = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8); + ctx.redirect(ApplicationProperties.frontendUrl() + "/login?error=" + errorUrlParam, HttpStatus.FOUND); + return; + } + + // FIXME: secure cookie with httpOnly + ctx.cookie("token", generateToken(user.netId()), 14400); + + redirect(ctx); + }; + + + + public static final Handler loginGet = ctx -> { + // check if already logged in + if (ctx.cookie("token") != null) { + redirect(ctx); + return; + } + ctx.redirect(CasService.getAuthorizationUrl()); + }; + + /** + * Redirects students to the class chat invite. At the time we used Slack, and therefore all references use + * that name + */ + private static void redirect(Context ctx) throws DataAccessException { + String redirectTo; + if(ctx.sessionAttribute("slack") != null) { + redirectTo = ConfigService.getSlackLink(); + ctx.sessionAttribute("slack", null); + } + else redirectTo = ApplicationProperties.frontendUrl(); + ctx.redirect(redirectTo, HttpStatus.FOUND); + } + + public static final Handler logoutPost = ctx -> { + if (ctx.cookie("token") == null) { + ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.UNAUTHORIZED); + return; + } + + // TODO: call cas logout endpoint with ticket + ctx.removeCookie("token", "/"); + ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.OK); + }; + +} diff --git a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java index 00d00d68e..6e3aa8779 100644 --- a/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java +++ b/src/main/java/edu/byu/cs/server/endpointprovider/EndpointProviderImpl.java @@ -93,17 +93,17 @@ public Handler meGet() { @Override public Handler callbackGet() { - return CasController.callbackGet; + return RedirectController.callbackGet; } @Override public Handler loginGet() { - return CasController.loginGet; + return RedirectController.loginGet; } @Override public Handler logoutPost() { - return CasController.logoutPost; + return RedirectController.logoutPost; } // ConfigController diff --git a/src/main/java/edu/byu/cs/service/CasService.java b/src/main/java/edu/byu/cs/service/CasService.java index 1f5129201..a2b418c5c 100644 --- a/src/main/java/edu/byu/cs/service/CasService.java +++ b/src/main/java/edu/byu/cs/service/CasService.java @@ -13,9 +13,9 @@ import java.util.NoSuchElementException; import java.util.Optional; -import javax.management.RuntimeErrorException; import javax.net.ssl.HttpsURLConnection; +import edu.byu.cs.controller.RedirectController; import edu.byu.cs.util.JwtUtils; import io.jsonwebtoken.security.JwkSet; import org.slf4j.Logger; @@ -36,7 +36,7 @@ import edu.byu.cs.properties.ApplicationProperties; /** - * Contains service logic for the {@link edu.byu.cs.controller.CasController}.
View the + * Contains service logic for the {@link RedirectController}.
View the * Berkeley CAS docs * to understand how CAS, or Central Authentication Service, works, if needed. *

From 0fb91996eb7bf26ea2b71b68fa6d7a7f3b19f3f6 Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 6 Jan 2026 17:09:22 -0700 Subject: [PATCH 06/34] Rename CasService to Authentiaction Service as CAS protocol is no longer used. Updated documentation --- .../byu/cs/controller/RedirectController.java | 8 +- ...ervice.java => AuthenticationService.java} | 573 +++++++++--------- 2 files changed, 288 insertions(+), 293 deletions(-) rename src/main/java/edu/byu/cs/service/{CasService.java => AuthenticationService.java} (69%) diff --git a/src/main/java/edu/byu/cs/controller/RedirectController.java b/src/main/java/edu/byu/cs/controller/RedirectController.java index a40e99656..a462ca10d 100644 --- a/src/main/java/edu/byu/cs/controller/RedirectController.java +++ b/src/main/java/edu/byu/cs/controller/RedirectController.java @@ -7,7 +7,7 @@ import edu.byu.cs.dataAccess.DataAccessException; import edu.byu.cs.model.User; import edu.byu.cs.properties.ApplicationProperties; -import edu.byu.cs.service.CasService; +import edu.byu.cs.service.AuthenticationService; import edu.byu.cs.service.ConfigService; import static edu.byu.cs.util.JwtUtils.generateToken; import io.javalin.http.Context; @@ -23,13 +23,13 @@ public class RedirectController { public static final Handler callbackGet = ctx -> { String code = ctx.queryParam("code"); //TODO: throw a fit if there's no code - CasService.TokenResponse response = CasService.exchangeCodeForTokens(code); + AuthenticationService.TokenResponse response = AuthenticationService.exchangeCodeForTokens(code); //String ticket = ctx.queryParam("ticket"); User user; try { - user = CasService.callback(response.idToken()); + user = AuthenticationService.callback(response.idToken()); } catch (CanvasException e) { String errorUrlParam = URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8); ctx.redirect(ApplicationProperties.frontendUrl() + "/login?error=" + errorUrlParam, HttpStatus.FOUND); @@ -50,7 +50,7 @@ public class RedirectController { redirect(ctx); return; } - ctx.redirect(CasService.getAuthorizationUrl()); + ctx.redirect(AuthenticationService.getAuthorizationUrl()); }; /** diff --git a/src/main/java/edu/byu/cs/service/CasService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java similarity index 69% rename from src/main/java/edu/byu/cs/service/CasService.java rename to src/main/java/edu/byu/cs/service/AuthenticationService.java index a2b418c5c..d0ab8163a 100644 --- a/src/main/java/edu/byu/cs/service/CasService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -1,289 +1,284 @@ -package edu.byu.cs.service; - -import java.io.IOException; -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.Collection; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Optional; - -import javax.net.ssl.HttpsURLConnection; - -import edu.byu.cs.controller.RedirectController; -import edu.byu.cs.util.JwtUtils; -import io.jsonwebtoken.security.JwkSet; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.dataformat.xml.XmlMapper; -import com.google.gson.Gson; -import com.google.gson.annotations.SerializedName; - -import edu.byu.cs.canvas.CanvasException; -import edu.byu.cs.canvas.CanvasService; -import edu.byu.cs.controller.exception.BadRequestException; -import edu.byu.cs.controller.exception.InternalServerException; -import edu.byu.cs.dataAccess.DaoService; -import edu.byu.cs.dataAccess.DataAccessException; -import edu.byu.cs.dataAccess.daoInterface.UserDao; -import edu.byu.cs.model.User; -import edu.byu.cs.properties.ApplicationProperties; - -/** - * Contains service logic for the {@link RedirectController}.
View the - * Berkeley CAS docs - * to understand how CAS, or Central Authentication Service, works, if needed. - *

- * The {@code CasService} ensures user authentication using BYU's CAS before they access - * and use the AutoGrader. - */ -public class CasService { - public static final String BYU_CAS_URL = "https://cas.byu.edu/cas"; - public static final String BYU_API_URL = "https://api-sandbox.byu.edu"; - private static final Logger LOGGER = LoggerFactory.getLogger(CasService.class); - - private static final HttpClient httpClient = HttpClient.newHttpClient(); - private static Instant configExpiration = Instant.now(); - private static Instant keyExpiration = Instant.now(); - - public static openIDConfig config; - private static JwkSet byuPublicKeys; - - /** - * Validates a CAS ticket and retrieves the associated user. - *
- * If the user exists in the database, they are returned directly. Otherwise, the user - * is retrieved from Canvas and stored in the database before being returned - * - * @param ticket the CAS ticket to validate - * @return the user, either stored in the database or from Canvas if not - * @throws InternalServerException if an error arose during ticket validation or user retrieval - * @throws BadRequestException if ticket validation failed - * @throws DataAccessException if there was an issue storing the user in the database - * @throws CanvasException if there was an issue getting the user from Canvas - */ - public static User callback(String ticket) throws InternalServerException, BadRequestException, DataAccessException, CanvasException { - String netId; - try { - netId = CasService.validateToken(ticket); - } catch (IOException | InterruptedException e) { - LOGGER.error("Error validating ticket", e); - throw new InternalServerException("Error validating ticket", e); - } - - if (netId == null) { - throw new BadRequestException("Ticket validation failed"); - } - - UserDao userDao = DaoService.getUserDao(); - - User user; - // Check if student is already in the database - try { - user = userDao.getUser(netId); - } catch (DataAccessException e) { - LOGGER.error("Couldn't get user from database", e); - throw new InternalServerException("Couldn't get user from database", e); - } - - // If there isn't a student in the database with this netId - if (user == null) { - try { - user = CanvasService.getCanvasIntegration().getUser(netId); - } catch (CanvasException e) { - LOGGER.error("Error getting user from canvas", e); - throw e; - } - - userDao.insertUser(user); - LOGGER.info("Registered {}", user); - } - return user; - } - - /** - * Validates a CAS ticket and returns the netId of the user if valid
- * Berkeley CAS docs - * - * @param ticket the ticket to validate - * @return the netId of the user if valid, null otherwise - * @throws IOException if there is an error with the CAS server response - */ - public static String validateCasTicket(String ticket) throws IOException { - String validationUrl = BYU_CAS_URL + "/serviceValidate" + "?ticket=" + ticket + "&service=" + ApplicationProperties.casCallbackUrl(); - - - URI uri = URI.create(validationUrl); - HttpsURLConnection connection = (HttpsURLConnection) uri.toURL().openConnection(); - - try { - String body = new String(connection.getInputStream().readAllBytes()); - - Map casServiceResponse = XmlMapper.builder().build().readValue(body, Map.class); - return (String) ((Map) casServiceResponse.get("authenticationSuccess")).get("user"); - - } catch (Exception e) { - LOGGER.error("Error with response from CAS server:", e); - throw e; - } finally { - connection.disconnect(); - } - } - - public static String validateToken(String token) throws InternalServerException, IOException, InterruptedException { - if (isExpired(keyExpiration)){ - if (isExpired(configExpiration)){ - cacheBYUOpenIDConfig(); - } - cacheJWK(); - - } - return JwtUtils.validateToken(token, byuPublicKeys); - } - - public static TokenResponse exchangeCodeForTokens(String code) throws IOException, InterruptedException, InternalServerException { - if (isExpired(configExpiration)){ - cacheBYUOpenIDConfig(); - } - - String formData = "grant_type=authorization_code" + - "&client_id=" + URLEncoder.encode(ApplicationProperties.clientId(), StandardCharsets.UTF_8) + - "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + - "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8); - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(config.tokenEndpoint)) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(formData)) - .build(); - - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - LOGGER.info(response.body()); - - return new Gson().fromJson(response.body(), TokenResponse.class); - - } - - - public record TokenResponse( - @SerializedName("access_token") String accessToken, - @SerializedName("id_token") String idToken, - @SerializedName("refresh_token") String refreshToken, - @SerializedName("expires_in") int expiresIn, - @SerializedName("token_type") String tokenType - - ) {} - - - /** - * Is only called once to get the inital OpenIDConfig - */ - public static void initalizeCache() { - try{ - cacheBYUOpenIDConfig(); - cacheJWK(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private static void cacheBYUOpenIDConfig() throws InternalServerException { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BYU_API_URL + "/.well-known/openid-configuration")) - .header("Accept", "application/json") - .GET() - .build(); - try{ - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - configExpiration = getCacheTime(response); - - openIDConfig config = new Gson().fromJson(response.body(), openIDConfig.class); - if (isValidConfig(config)){ - CasService.config = config; - } - else { - throw new Exception("Invalid OpenIDConfig"); - } - - } catch (Exception e){ - LOGGER.error("Unable to pull openid config from BYU:", e); - throw new InternalServerException("Unable to determine identity", e); - } - } - - private static void cacheJWK () throws IOException, InterruptedException, InternalServerException { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(config.keyUri)) - .header("Accept", "application/json") - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - keyExpiration = getCacheTime(response); - - byuPublicKeys = JwtUtils.readJWKs(response.body()); - - } - - private static Instant getCacheTime(HttpResponse response) throws InternalServerException { - Optional cache = response.headers().firstValue("Cache-Control"); - try{ - String seconds = cache.get().replace("max-age=", ""); - return Instant.now().plusSeconds(Long.parseLong(seconds)); - } - catch (NoSuchElementException e) { - throw new InternalServerException("Unable to determine cache time", e); - } - } - - public record openIDConfig( - String issuer, - @SerializedName("authorization_endpoint") String authorizationEndpoint, - @SerializedName("token_endpoint") String tokenEndpoint, - @SerializedName("jwks_uri") String keyUri, - @SerializedName("scopes_supported") Collection scopes, - @SerializedName("id_token_signing_alg_values_supported")Collection encryptions - ){}; - - - private static boolean isValidConfig(openIDConfig config){ - if (!config.issuer().equals(BYU_API_URL)){ - return false; - } - if (!config.equals(CasService.config) && CasService.config != null){ - LOGGER.info("OpenID config has changed: {}", config); - } - if (config.scopes().size()!= 1){ - LOGGER.warn("Config has multiple scopes: {}", config); - } - if (config.encryptions().size()!=1){ - LOGGER.warn("Config has multiple encryption types: {}", config); - } - return config.authorizationEndpoint.contains(BYU_API_URL) && config.tokenEndpoint().contains(BYU_API_URL) && - config.keyUri().contains(BYU_API_URL); - } - - private static boolean isExpired(Instant time){ - return time.isBefore(Instant.now()); - } - - public static String getAuthorizationUrl() throws InternalServerException{ - if (isExpired(configExpiration)){ - cacheBYUOpenIDConfig(); - } - return CasService.config.authorizationEndpoint() + "?response_type=code" + "&client_id="+ - ApplicationProperties.clientId() + "&redirect_uri=" + ApplicationProperties.casCallbackUrl() - + "&scope=openid"; - } -} +package edu.byu.cs.service; + +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Collection; +import java.util.NoSuchElementException; +import java.util.Optional; + +import edu.byu.cs.controller.RedirectController; +import edu.byu.cs.util.JwtUtils; +import io.jsonwebtoken.security.JwkSet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.annotations.SerializedName; + +import edu.byu.cs.canvas.CanvasException; +import edu.byu.cs.canvas.CanvasService; +import edu.byu.cs.controller.exception.BadRequestException; +import edu.byu.cs.controller.exception.InternalServerException; +import edu.byu.cs.dataAccess.DaoService; +import edu.byu.cs.dataAccess.DataAccessException; +import edu.byu.cs.dataAccess.daoInterface.UserDao; +import edu.byu.cs.model.User; +import edu.byu.cs.properties.ApplicationProperties; + +/** + * Contains service logic for the {@link RedirectController}.
View the + * BYU API documentation + * to understand OAuth works, if needed. Other sites on this page are a great resource as well, + * particularly verifying JWT tokens. You should also check out the {@link JwtUtils} class as well. + *

+ * The {@code CasService} ensures the user Authenticates before they access + * and use the AutoGrader. + */ +public class AuthenticationService { + public static final String BYU_API_URL = "https://api-sandbox.byu.edu"; + private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticationService.class); + + private static final HttpClient httpClient = HttpClient.newHttpClient(); + private static Instant configExpiration = Instant.now(); + private static Instant keyExpiration = Instant.now(); + + public static OpenIDConfig config; + private static JwkSet byuPublicKeys; + + /** + * Validates an identity token and retrieves the associated user. + *
+ * If the user exists in the database, they are returned directly. Otherwise, the user + * is retrieved from Canvas and stored in the database before being returned + * + * @param ticket the identity token in the form of a JWT + * @return the user, either stored in the database or from Canvas if not + * @throws InternalServerException if an error arose during ticket validation or user retrieval + * @throws BadRequestException if JWT validation failed + * @throws DataAccessException if there was an issue storing the user in the database + * @throws CanvasException if there was an issue getting the user from Canvas + */ + public static User callback(String ticket) throws InternalServerException, BadRequestException, DataAccessException, CanvasException { + String netId; + try { + netId = AuthenticationService.validateToken(ticket); + } catch (IOException | InterruptedException e) { + LOGGER.error("Error validating ticket", e); + throw new InternalServerException("Error validating ticket", e); + } + + if (netId == null) { + throw new BadRequestException("Ticket validation failed"); + } + + UserDao userDao = DaoService.getUserDao(); + + User user; + // Check if student is already in the database + try { + user = userDao.getUser(netId); + } catch (DataAccessException e) { + LOGGER.error("Couldn't get user from database", e); + throw new InternalServerException("Couldn't get user from database", e); + } + + // If there isn't a student in the database with this netId + if (user == null) { + try { + user = CanvasService.getCanvasIntegration().getUser(netId); + } catch (CanvasException e) { + LOGGER.error("Error getting user from canvas", e); + throw e; + } + + userDao.insertUser(user); + LOGGER.info("Registered {}", user); + } + return user; + } + + /** + * + * @param token the JWT token to validate + * @return the JWT subject (currently the netid) + * @throws InternalServerException when unable to grab keys or OpenID config + */ + public static String validateToken(String token) throws InternalServerException, IOException, InterruptedException { + if (isExpired(keyExpiration)){ + if (isExpired(configExpiration)){ + cacheBYUOpenIDConfig(); + } + cacheJWK(); + + } + return JwtUtils.validateToken(token, byuPublicKeys); + } + + public static TokenResponse exchangeCodeForTokens(String code) throws IOException, InterruptedException, InternalServerException { + if (isExpired(configExpiration)){ + cacheBYUOpenIDConfig(); + } + + String formData = "grant_type=authorization_code" + + "&client_id=" + URLEncoder.encode(ApplicationProperties.clientId(), StandardCharsets.UTF_8) + + "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + + "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.tokenEndpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(formData)) + .build(); + + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + LOGGER.info(response.body()); + + return new Gson().fromJson(response.body(), TokenResponse.class); + + } + + + public record TokenResponse( + @SerializedName("access_token") String accessToken, + @SerializedName("id_token") String idToken, + @SerializedName("refresh_token") String refreshToken, + @SerializedName("expires_in") int expiresIn, + @SerializedName("token_type") String tokenType + + ) {} + + /** + * Caches the info from the api needed to complete the OAuth transaction. + * @throws InternalServerException when there is something suspicious about the OpenID config + */ + private static void cacheBYUOpenIDConfig() throws InternalServerException, IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(BYU_API_URL + "/.well-known/openid-configuration")) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + configExpiration = getCacheTime(response); + + OpenIDConfig config = new Gson().fromJson(response.body(), OpenIDConfig.class); + if (isValidConfig(config)){ + AuthenticationService.config = config; + } + else { + throw new InternalServerException("Unable to verify OpenID config", null); + } + + } + + /** + * Grabs a set of JWKs from the endpoint specified in the config. These are public keys used to verify that + * JWTs received are in fact from BYU. The sandbox api should usually only have one at a time, but they + * can rotate the keys whenever, so we must be able to account for multiple. + */ + private static void cacheJWK () throws IOException, InterruptedException, InternalServerException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(config.keyUri)) + .header("Accept", "application/json") + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + keyExpiration = getCacheTime(response); + + byuPublicKeys = JwtUtils.readJWKs(response.body()); + + } + + /** + * Grabs the Cache-Control header so that the config and keys are refreshed according to the BYU settings + * + * @param response http response with Cache-Control header + * @return Expire time of given response + * @throws InternalServerException if unable to find the Cache-Control header + */ + private static Instant getCacheTime(HttpResponse response) throws InternalServerException { + Optional cache = response.headers().firstValue("Cache-Control"); + try{ + String seconds = cache.get().replace("max-age=", ""); + return Instant.now().plusSeconds(Long.parseLong(seconds)); + } + catch (NoSuchElementException e) { + throw new InternalServerException("Unable to determine cache time", e); + } + } + + /** + * Some of the fields delivered for the OpenID config. Call the endpoint yourself to see the full config. + * @param issuer - should be a byu api, and the specific API called + * @param authorizationEndpoint - where the browser should redirect the user on login + * @param tokenEndpoint - where the browser should confirm the redirect worked + * @param keyUri - where public keys to verify JWT tokens are received from + * @param scopes - only scope currently is openid + * @param encryptions - types of encryptions supported when signing the JWT tokens + */ + public record OpenIDConfig( + String issuer, + @SerializedName("authorization_endpoint") String authorizationEndpoint, + @SerializedName("token_endpoint") String tokenEndpoint, + @SerializedName("jwks_uri") String keyUri, + @SerializedName("scopes_supported") Collection scopes, + @SerializedName("id_token_signing_alg_values_supported")Collection encryptions + ){}; + + /** + * Ensures the config came from the issuer, BYU API, and that any redirect links also are from the BYU API. + *

+ * Also logs any changes to the OpenID config that may need to be looked at. + * @param config + * @return + */ + private static boolean isValidConfig(OpenIDConfig config){ + if (!config.issuer().equals(BYU_API_URL)){ + return false; + } + if (!config.equals(AuthenticationService.config) && AuthenticationService.config != null){ + LOGGER.info("OpenID config has changed: {}", config); + } + if (config.scopes().size()!= 1){ + LOGGER.warn("Config has multiple scopes: {}", config); + } + if (config.encryptions().size()!=1){ + LOGGER.warn("Config has multiple encryption types: {}", config); + } + return config.authorizationEndpoint.contains(BYU_API_URL) && config.tokenEndpoint().contains(BYU_API_URL) && + config.keyUri().contains(BYU_API_URL); + } + + private static boolean isExpired(Instant time){ + return time.isBefore(Instant.now()); + } + + /** + * @return authorization url with parameters filled in + * @throws InternalServerException when unable to reload OpenID config + */ + public static String getAuthorizationUrl() throws InternalServerException{ + try { + if (isExpired(configExpiration)) { + cacheBYUOpenIDConfig(); + } + } catch (IOException | InterruptedException e){ + LOGGER.error("Unable to cache OpenID Config", e); + throw new InternalServerException("Unable to verify identity", e); + } + return AuthenticationService.config.authorizationEndpoint() + "?response_type=code" + "&client_id="+ + ApplicationProperties.clientId() + "&redirect_uri=" + ApplicationProperties.casCallbackUrl() + + "&scope=openid"; + } +} From f10d9210de78631c41469d441234232a3610aa54 Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 6 Jan 2026 17:10:52 -0700 Subject: [PATCH 07/34] fix documentation typo --- src/main/java/edu/byu/cs/service/AuthenticationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index d0ab8163a..0c1f69490 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -37,7 +37,7 @@ * to understand OAuth works, if needed. Other sites on this page are a great resource as well, * particularly verifying JWT tokens. You should also check out the {@link JwtUtils} class as well. *

- * The {@code CasService} ensures the user Authenticates before they access + * The {@code AuthenticationService} ensures the user authenticates before they access * and use the AutoGrader. */ public class AuthenticationService { From ff14d715a19d71f0eb95b36dcc8288d76c2087ad Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 6 Jan 2026 17:57:38 -0700 Subject: [PATCH 08/34] add logic for multiple keys --- .../byu/cs/service/AuthenticationService.java | 7 ++-- src/main/java/edu/byu/cs/util/JwtUtils.java | 37 ++++++++++++++++--- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index 0c1f69490..a8f74ba7a 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -14,7 +14,6 @@ import edu.byu.cs.controller.RedirectController; import edu.byu.cs.util.JwtUtils; -import io.jsonwebtoken.security.JwkSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,7 +48,7 @@ public class AuthenticationService { private static Instant keyExpiration = Instant.now(); public static OpenIDConfig config; - private static JwkSet byuPublicKeys; + /** * Validates an identity token and retrieves the associated user. @@ -117,7 +116,7 @@ public static String validateToken(String token) throws InternalServerException, cacheJWK(); } - return JwtUtils.validateToken(token, byuPublicKeys); + return JwtUtils.validateTokenAgainstKeys(token); } public static TokenResponse exchangeCodeForTokens(String code) throws IOException, InterruptedException, InternalServerException { @@ -196,7 +195,7 @@ private static void cacheJWK () throws IOException, InterruptedException, Intern keyExpiration = getCacheTime(response); - byuPublicKeys = JwtUtils.readJWKs(response.body()); + JwtUtils.readJWKs(response.body()); } diff --git a/src/main/java/edu/byu/cs/util/JwtUtils.java b/src/main/java/edu/byu/cs/util/JwtUtils.java index 809773c7a..596d4f7fc 100644 --- a/src/main/java/edu/byu/cs/util/JwtUtils.java +++ b/src/main/java/edu/byu/cs/util/JwtUtils.java @@ -1,6 +1,7 @@ package edu.byu.cs.util; -import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Jwk; import io.jsonwebtoken.security.JwkSet; import io.jsonwebtoken.security.Jwks; import org.slf4j.Logger; @@ -8,6 +9,7 @@ import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; +import java.security.Key; import java.security.NoSuchAlgorithmException; import java.security.PublicKey; import java.security.SecureRandom; @@ -23,6 +25,7 @@ */ public class JwtUtils { private static final SecretKey key = generateSecretKey(); + private static JwkSet byuPublicKeys; private static final Logger LOGGER = LoggerFactory.getLogger(JwtUtils.class); @@ -73,10 +76,11 @@ private static SecretKey generateSecretKey() { return keyGenerator.generateKey(); } - public static String validateToken(String token, JwkSet keys){ + public static String validateTokenAgainstKeys(String token){ String netid = null; - if (keys.size() == 1){ - var key = keys.getKeys().stream().findFirst().get().toKey(); + //one key short circuit + if (byuPublicKeys.size() == 1){ + var key = byuPublicKeys.getKeys().stream().findFirst().get().toKey(); try { netid = Jwts.parser() .verifyWith((PublicKey) key) @@ -88,12 +92,35 @@ public static String validateToken(String token, JwkSet keys){ LOGGER.error("Unable to verify with JWK:",e); } } + else{ + netid = Jwts.parser() + .keyLocator(locator) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } return netid; } public static JwkSet readJWKs(String json){ - return Jwks.setParser() + var keys = Jwks.setParser() .build() .parse(json); + byuPublicKeys = keys; + return keys; } + + static Locator locator = new Locator<>() { + @Override + public Key locate(Header header) { + for (Jwk key : byuPublicKeys) { + if (((ProtectedHeader) header).getKeyId().equals(key.getId())) { + return key.toKey(); + } + } + return null; + } + }; + } From 1bf1866d3aae0df1344eb1747ec4d27a6b8a0113 Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 7 Jan 2026 17:26:08 -0700 Subject: [PATCH 09/34] add documentation to getting-started.md --- docs/getting-started/getting-started.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/getting-started/getting-started.md b/docs/getting-started/getting-started.md index 05f4b081f..f4ec01f27 100644 --- a/docs/getting-started/getting-started.md +++ b/docs/getting-started/getting-started.md @@ -109,6 +109,11 @@ Do the following actions: # Use one of the following, but not both --canvas-token --use-canvas false + +#Follow the steps at https://developer.byu.edu/data/api-usage/create-an-oauth-client +#You are going to want to choose the Auth Code + PKCE option +#For the redirect url, you should use the cas-callback-url +--client-id ``` ### 6. Run the Autograder Locally From 9fdce152ed785376b4859a4d5ee856e138737b2e Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 7 Jan 2026 17:50:54 -0700 Subject: [PATCH 10/34] improve validation for AuthenticationService.java --- .../byu/cs/service/AuthenticationService.java | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index a8f74ba7a..bc1a9b725 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -139,8 +139,6 @@ public static TokenResponse exchangeCodeForTokens(String code) throws IOExceptio HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - LOGGER.info(response.body()); - return new Gson().fromJson(response.body(), TokenResponse.class); } @@ -255,14 +253,39 @@ private static boolean isValidConfig(OpenIDConfig config){ if (config.encryptions().size()!=1){ LOGGER.warn("Config has multiple encryption types: {}", config); } - return config.authorizationEndpoint.contains(BYU_API_URL) && config.tokenEndpoint().contains(BYU_API_URL) && - config.keyUri().contains(BYU_API_URL); + return isValidUrl(config.authorizationEndpoint) && isValidUrl(config.tokenEndpoint()) && + isValidUrl(config.keyUri()); } private static boolean isExpired(Instant time){ return time.isBefore(Instant.now()); } + /** + * Validates that a URL uses HTTPS and has the same host as the BYU API URL. + * @param urlString the URL to validate + * @return true if the URL is valid, false otherwise + */ + private static boolean isValidUrl(String urlString) { + try { + URI uri = new URI(urlString); + URI baseUri = new URI(BYU_API_URL); + + // Verify HTTPS is used + if (!"https".equals(uri.getScheme())) { + return false; + } + + // Verify the host matches the base API URL's host + String host = uri.getHost(); + String expectedHost = baseUri.getHost(); + return host != null && host.equals(expectedHost); + + } catch (Exception e) { + return false; + } + } + /** * @return authorization url with parameters filled in * @throws InternalServerException when unable to reload OpenID config @@ -276,8 +299,9 @@ public static String getAuthorizationUrl() throws InternalServerException{ LOGGER.error("Unable to cache OpenID Config", e); throw new InternalServerException("Unable to verify identity", e); } - return AuthenticationService.config.authorizationEndpoint() + "?response_type=code" + "&client_id="+ - ApplicationProperties.clientId() + "&redirect_uri=" + ApplicationProperties.casCallbackUrl() - + "&scope=openid"; + return URLEncoder.encode(AuthenticationService.config.authorizationEndpoint(), StandardCharsets.UTF_8) + + "?response_type=code" + "&client_id="+ URLEncoder.encode(ApplicationProperties.clientId(), StandardCharsets.UTF_8) + + "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8) + + "&scope=openid"; } } From f897f64d1102dc9ceaa81fdd5b73b8e888073d9b Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 7 Jan 2026 18:09:42 -0700 Subject: [PATCH 11/34] move appropriate get request logic to NetworkUtils --- .../byu/cs/service/AuthenticationService.java | 35 ++++------------- .../java/edu/byu/cs/util/NetworkUtils.java | 38 +++++++++++++++++-- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index bc1a9b725..6d590f54b 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -14,6 +14,7 @@ import edu.byu.cs.controller.RedirectController; import edu.byu.cs.util.JwtUtils; +import edu.byu.cs.util.NetworkUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -163,9 +164,10 @@ private static void cacheBYUOpenIDConfig() throws InternalServerException, IOExc .header("Accept", "application/json") .GET() .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = NetworkUtils.makeJsonGetRequest(BYU_API_URL + + "/.well-known/openid-configuration"); - configExpiration = getCacheTime(response); + configExpiration = NetworkUtils.getCacheTime(response); OpenIDConfig config = new Gson().fromJson(response.body(), OpenIDConfig.class); if (isValidConfig(config)){ @@ -183,38 +185,15 @@ private static void cacheBYUOpenIDConfig() throws InternalServerException, IOExc * can rotate the keys whenever, so we must be able to account for multiple. */ private static void cacheJWK () throws IOException, InterruptedException, InternalServerException { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(config.keyUri)) - .header("Accept", "application/json") - .GET() - .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = NetworkUtils.makeJsonGetRequest(config.keyUri); - keyExpiration = getCacheTime(response); + keyExpiration = NetworkUtils.getCacheTime(response); JwtUtils.readJWKs(response.body()); } - /** - * Grabs the Cache-Control header so that the config and keys are refreshed according to the BYU settings - * - * @param response http response with Cache-Control header - * @return Expire time of given response - * @throws InternalServerException if unable to find the Cache-Control header - */ - private static Instant getCacheTime(HttpResponse response) throws InternalServerException { - Optional cache = response.headers().firstValue("Cache-Control"); - try{ - String seconds = cache.get().replace("max-age=", ""); - return Instant.now().plusSeconds(Long.parseLong(seconds)); - } - catch (NoSuchElementException e) { - throw new InternalServerException("Unable to determine cache time", e); - } - } - /** * Some of the fields delivered for the OpenID config. Call the endpoint yourself to see the full config. * @param issuer - should be a byu api, and the specific API called @@ -238,7 +217,7 @@ public record OpenIDConfig( *

* Also logs any changes to the OpenID config that may need to be looked at. * @param config - * @return + * @return true if valid, false if there's a glaring problem */ private static boolean isValidConfig(OpenIDConfig config){ if (!config.issuer().equals(BYU_API_URL)){ diff --git a/src/main/java/edu/byu/cs/util/NetworkUtils.java b/src/main/java/edu/byu/cs/util/NetworkUtils.java index d1d0e3cdf..848e85c5d 100644 --- a/src/main/java/edu/byu/cs/util/NetworkUtils.java +++ b/src/main/java/edu/byu/cs/util/NetworkUtils.java @@ -1,5 +1,6 @@ package edu.byu.cs.util; +import edu.byu.cs.controller.exception.InternalServerException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -8,6 +9,9 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Instant; +import java.util.NoSuchElementException; +import java.util.Optional; /** * A utility class that provides methods for making HTTP Requests @@ -17,15 +21,17 @@ public class NetworkUtils { private static final Logger LOGGER = LoggerFactory.getLogger(NetworkUtils.class); /** - * Leverages the built-in {@link java.net.http.HttpClient} library to make a basic HTTP Get request. + * Leverages the built-in {@link java.net.http.HttpClient} library to make an HTTP Get request. + * Requires a json response. * * @param url The URL to request * @return The {@link HttpResponse} response, or the errors generated in the process. */ - public static HttpResponse makeGetRequest(String url) throws IOException, InterruptedException { + public static HttpResponse makeJsonGetRequest(String url) throws IOException, InterruptedException { try (HttpClient httpClient = HttpClient.newHttpClient()) { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) + .header("Accept", "application/json") .GET() // HTTP GET method .build(); @@ -41,7 +47,11 @@ public static HttpResponse makeGetRequest(String url) throws IOException */ public static String readGetRequestBody(String url) { try { - HttpResponse response = makeGetRequest(url); + HttpResponse response = makeJsonGetRequest(url); + if (!isSuccessful(response.statusCode())){ + LOGGER.warn("Error making GET request to '{}': {} status returned", url, response.statusCode()); + return null; + } return response.body(); } catch (IOException | InterruptedException e) { System.err.print("Error making GET request to '" + url + "': " + e.getMessage()); @@ -49,4 +59,26 @@ public static String readGetRequestBody(String url) { return null; } } + + private static boolean isSuccessful(int status){ + return status /100 == 2; + } + + /** + * Grabs the Instant that the Cache-Control indicates expiration + * + * @param response http response with Cache-Control header + * @return Expire time of given response + * @throws InternalServerException if unable to find the Cache-Control header + */ + public static Instant getCacheTime(HttpResponse response) throws InternalServerException { + Optional cache = response.headers().firstValue("Cache-Control"); + try{ + String seconds = cache.get().replace("max-age=", ""); + return Instant.now().plusSeconds(Long.parseLong(seconds)); + } + catch (NoSuchElementException e) { + throw new InternalServerException("Unable to determine cache time", e); + } + } } From 72a3ebab3084845263375624efe9c499cf77898a Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 7 Jan 2026 18:38:30 -0700 Subject: [PATCH 12/34] fix url encoding --- .../java/edu/byu/cs/service/AuthenticationService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index 6d590f54b..e4f6ddbbf 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -278,9 +278,9 @@ public static String getAuthorizationUrl() throws InternalServerException{ LOGGER.error("Unable to cache OpenID Config", e); throw new InternalServerException("Unable to verify identity", e); } - return URLEncoder.encode(AuthenticationService.config.authorizationEndpoint(), StandardCharsets.UTF_8) - + "?response_type=code" + "&client_id="+ URLEncoder.encode(ApplicationProperties.clientId(), StandardCharsets.UTF_8) - + "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8) - + "&scope=openid"; + return AuthenticationService.config.authorizationEndpoint() + + "?response_type=code&client_id=" + URLEncoder.encode(ApplicationProperties.clientId(), StandardCharsets.UTF_8) + + "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8) + + "&scope=" + URLEncoder.encode("openid", StandardCharsets.UTF_8); } } From a89cdb9a74ad8028d914d9bc839447f67c570e74 Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 7 Jan 2026 19:08:26 -0700 Subject: [PATCH 13/34] throw unauthorized exception when the callback is not called with a code and secure http cookie --- .../byu/cs/controller/RedirectController.java | 19 +++++++++++++++---- .../byu/cs/service/AuthenticationService.java | 9 +++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/main/java/edu/byu/cs/controller/RedirectController.java b/src/main/java/edu/byu/cs/controller/RedirectController.java index a462ca10d..1d0d47a22 100644 --- a/src/main/java/edu/byu/cs/controller/RedirectController.java +++ b/src/main/java/edu/byu/cs/controller/RedirectController.java @@ -4,6 +4,7 @@ import java.nio.charset.StandardCharsets; import edu.byu.cs.canvas.CanvasException; +import edu.byu.cs.controller.exception.UnauthorizedException; import edu.byu.cs.dataAccess.DataAccessException; import edu.byu.cs.model.User; import edu.byu.cs.properties.ApplicationProperties; @@ -11,6 +12,7 @@ import edu.byu.cs.service.ConfigService; import static edu.byu.cs.util.JwtUtils.generateToken; import io.javalin.http.Context; +import io.javalin.http.Cookie; import io.javalin.http.Handler; import io.javalin.http.HttpStatus; @@ -22,7 +24,9 @@ public class RedirectController { public static final Handler callbackGet = ctx -> { String code = ctx.queryParam("code"); - //TODO: throw a fit if there's no code + if (code == null){ + throw new UnauthorizedException(); + } AuthenticationService.TokenResponse response = AuthenticationService.exchangeCodeForTokens(code); //String ticket = ctx.queryParam("ticket"); @@ -36,8 +40,15 @@ public class RedirectController { return; } - // FIXME: secure cookie with httpOnly - ctx.cookie("token", generateToken(user.netId()), 14400); + ctx.cookie (new Cookie( + "token", + generateToken(user.netId()), + "/", + 14400, + AuthenticationService.isSecure(), + 0, + true + )); redirect(ctx); }; @@ -73,7 +84,7 @@ private static void redirect(Context ctx) throws DataAccessException { return; } - // TODO: call cas logout endpoint with ticket + // TODO: call logout endpoint with token ctx.removeCookie("token", "/"); ctx.redirect(ApplicationProperties.frontendUrl(), HttpStatus.OK); }; diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index e4f6ddbbf..c8fc90495 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -9,8 +9,6 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Collection; -import java.util.NoSuchElementException; -import java.util.Optional; import edu.byu.cs.controller.RedirectController; import edu.byu.cs.util.JwtUtils; @@ -283,4 +281,11 @@ public static String getAuthorizationUrl() throws InternalServerException{ + "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8) + "&scope=" + URLEncoder.encode("openid", StandardCharsets.UTF_8); } + + /** + * Evaluates the frontend url and if it is secure returns true. + */ + public static boolean isSecure() { + return ApplicationProperties.frontendUrl().startsWith("https"); + } } From 54cc3d3900e3ca9859ffdfa7a4cf074df65e68c0 Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 7 Jan 2026 19:09:47 -0700 Subject: [PATCH 14/34] remove unused code and improve documentation --- .../java/edu/byu/cs/service/AuthenticationService.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index c8fc90495..62ba61e76 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -157,11 +157,6 @@ public record TokenResponse( * @throws InternalServerException when there is something suspicious about the OpenID config */ private static void cacheBYUOpenIDConfig() throws InternalServerException, IOException, InterruptedException { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BYU_API_URL + "/.well-known/openid-configuration")) - .header("Accept", "application/json") - .GET() - .build(); HttpResponse response = NetworkUtils.makeJsonGetRequest(BYU_API_URL + "/.well-known/openid-configuration"); @@ -208,13 +203,13 @@ public record OpenIDConfig( @SerializedName("jwks_uri") String keyUri, @SerializedName("scopes_supported") Collection scopes, @SerializedName("id_token_signing_alg_values_supported")Collection encryptions - ){}; + ){} /** * Ensures the config came from the issuer, BYU API, and that any redirect links also are from the BYU API. *

* Also logs any changes to the OpenID config that may need to be looked at. - * @param config + * @param config an OpenID config * @return true if valid, false if there's a glaring problem */ private static boolean isValidConfig(OpenIDConfig config){ From 497119c8f879e5e43c770c62c1e55e2e6d6fdd9e Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 7 Jan 2026 19:47:30 -0700 Subject: [PATCH 15/34] move the status code check into the function that makes the request --- src/main/java/edu/byu/cs/util/NetworkUtils.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/edu/byu/cs/util/NetworkUtils.java b/src/main/java/edu/byu/cs/util/NetworkUtils.java index 848e85c5d..250540805 100644 --- a/src/main/java/edu/byu/cs/util/NetworkUtils.java +++ b/src/main/java/edu/byu/cs/util/NetworkUtils.java @@ -35,7 +35,11 @@ public static HttpResponse makeJsonGetRequest(String url) throws IOExcep .GET() // HTTP GET method .build(); - return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (!isSuccessful(response.statusCode())){ + LOGGER.warn("Error making GET request to '{}': {} status returned", url, response.statusCode()); + } + return response; } } @@ -48,10 +52,7 @@ public static HttpResponse makeJsonGetRequest(String url) throws IOExcep public static String readGetRequestBody(String url) { try { HttpResponse response = makeJsonGetRequest(url); - if (!isSuccessful(response.statusCode())){ - LOGGER.warn("Error making GET request to '{}': {} status returned", url, response.statusCode()); - return null; - } + return response.body(); } catch (IOException | InterruptedException e) { System.err.print("Error making GET request to '" + url + "': " + e.getMessage()); From 708838a0f4ca899c6f94c5531ab88da49c14b47a Mon Sep 17 00:00:00 2001 From: mewilker Date: Wed, 7 Jan 2026 19:57:25 -0700 Subject: [PATCH 16/34] extract all possible networking logic --- .../byu/cs/service/AuthenticationService.java | 12 +--------- .../java/edu/byu/cs/util/NetworkUtils.java | 23 ++++++++++++++++--- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index 62ba61e76..b81fe231e 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -3,8 +3,6 @@ import java.io.IOException; import java.net.URI; import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Instant; @@ -42,7 +40,6 @@ public class AuthenticationService { public static final String BYU_API_URL = "https://api-sandbox.byu.edu"; private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticationService.class); - private static final HttpClient httpClient = HttpClient.newHttpClient(); private static Instant configExpiration = Instant.now(); private static Instant keyExpiration = Instant.now(); @@ -128,15 +125,8 @@ public static TokenResponse exchangeCodeForTokens(String code) throws IOExceptio "&code=" + URLEncoder.encode(code, StandardCharsets.UTF_8) + "&redirect_uri=" + URLEncoder.encode(ApplicationProperties.casCallbackUrl(), StandardCharsets.UTF_8); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(config.tokenEndpoint)) - .header("Content-Type", "application/x-www-form-urlencoded") - .header("Accept", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(formData)) - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = NetworkUtils.makeParameterizedPostRequest(config.tokenEndpoint, formData); return new Gson().fromJson(response.body(), TokenResponse.class); diff --git a/src/main/java/edu/byu/cs/util/NetworkUtils.java b/src/main/java/edu/byu/cs/util/NetworkUtils.java index 250540805..0e15f20b6 100644 --- a/src/main/java/edu/byu/cs/util/NetworkUtils.java +++ b/src/main/java/edu/byu/cs/util/NetworkUtils.java @@ -36,13 +36,30 @@ public static HttpResponse makeJsonGetRequest(String url) throws IOExcep .build(); var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (!isSuccessful(response.statusCode())){ + if (isFailure(response.statusCode())){ LOGGER.warn("Error making GET request to '{}': {} status returned", url, response.statusCode()); } return response; } } + public static HttpResponse makeParameterizedPostRequest(String url, String formData) throws IOException, InterruptedException { + try(HttpClient httpClient = HttpClient.newHttpClient()){ + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(formData)) + .build(); + var response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (isFailure(response.statusCode())){ + LOGGER.warn("Error making POST request to '{}': {} status returned with form data: {}", + url, response.statusCode(), formData); + } + return response; + } + } + /** * Makes an HTTP Get request and returns the body text, or null if an error occurs. * @@ -61,8 +78,8 @@ public static String readGetRequestBody(String url) { } } - private static boolean isSuccessful(int status){ - return status /100 == 2; + private static boolean isFailure(int status){ + return status / 100 != 2; } /** From 47b5edcfd1913af033d84eac0f574b73b91c3b35 Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 13 Jan 2026 19:21:26 -0700 Subject: [PATCH 17/34] create AuthenticationService unit tests --- .../byu/cs/service/AuthenticationService.java | 7 +- .../service/AuthenticationServiceTests.java | 192 ++++++++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index b81fe231e..d70c619c7 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -40,8 +40,9 @@ public class AuthenticationService { public static final String BYU_API_URL = "https://api-sandbox.byu.edu"; private static final Logger LOGGER = LoggerFactory.getLogger(AuthenticationService.class); - private static Instant configExpiration = Instant.now(); - private static Instant keyExpiration = Instant.now(); + //initializing these 30 seconds behind the current time ensures the config is cached on start + private static Instant configExpiration = Instant.now().minusSeconds(30); + private static Instant keyExpiration = Instant.now().minusSeconds(30); public static OpenIDConfig config; @@ -142,6 +143,8 @@ public record TokenResponse( ) {} + //FIXME: make sure cache control is still valid + /** * Caches the info from the api needed to complete the OAuth transaction. * @throws InternalServerException when there is something suspicious about the OpenID config diff --git a/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java b/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java new file mode 100644 index 000000000..1cc2d4b36 --- /dev/null +++ b/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java @@ -0,0 +1,192 @@ +package edu.byu.cs.service; + +import com.google.gson.Gson; +import edu.byu.cs.controller.exception.BadRequestException; +import edu.byu.cs.controller.exception.InternalServerException; +import edu.byu.cs.dataAccess.DaoService; +import edu.byu.cs.dataAccess.daoInterface.UserDao; +import edu.byu.cs.model.User; +import edu.byu.cs.properties.ApplicationProperties; +import edu.byu.cs.util.JwtUtils; +import edu.byu.cs.util.NetworkUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.net.http.HttpResponse; +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class AuthenticationServiceTest { + + private UserDao mockUserDao; + private User testUser; + + + @BeforeEach + void setUp() { + mockUserDao = mock(UserDao.class); + testUser = new User("test_netid", + 0, + "FirstName", + "LastName", + null, + User.Role.ADMIN); + } + + // ==================== callback() Tests ==================== + + //tests valid url and valid config + @Test + @DisplayName("callback should return user from database if user exists") + void callback_returnUserFromDatabase_whenUserExists() throws Exception { + try (MockedStatic daoServiceMock = mockStatic(DaoService.class); + MockedStatic jwtUtilsMock = mockStatic(JwtUtils.class); + MockedStatic networkUtilsMock = mockStatic(NetworkUtils.class)) { + + daoServiceMock.when(DaoService::getUserDao).thenReturn(mockUserDao); + when(mockUserDao.getUser("test_netid")).thenReturn(testUser); + jwtUtilsMock.when(() -> JwtUtils.validateTokenAgainstKeys("valid_token")).thenReturn("test_netid"); + + setupMockedValidOpenIDConfig(networkUtilsMock); + + User result = AuthenticationService.callback("valid_token"); + + assertEquals(testUser, result); + verify(mockUserDao).getUser("test_netid"); + verify(mockUserDao, never()).insertUser(any()); + } + } + + @Test + @DisplayName("callback should fetch user from Canvas and store in database if user doesn't exist") + void callback_fetchFromCanvasAndStore_whenUserNotInDatabase() throws Exception { + try (MockedStatic daoServiceMock = mockStatic(DaoService.class); + MockedStatic jwtUtilsMock = mockStatic(JwtUtils.class); + MockedStatic networkUtilsMock = mockStatic(NetworkUtils.class); + MockedStatic appPropsMock = mockStatic(ApplicationProperties.class)) { + + daoServiceMock.when(DaoService::getUserDao).thenReturn(mockUserDao); + when(mockUserDao.getUser("test_netid")).thenReturn(null); + jwtUtilsMock.when(() -> JwtUtils.validateTokenAgainstKeys("valid_token")).thenReturn("test_netid"); + + appPropsMock.when(ApplicationProperties::useCanvas).thenReturn(false); + + setupMockedValidOpenIDConfig(networkUtilsMock); + + User result = AuthenticationService.callback("valid_token"); + + assertEquals(testUser, result); + verify(mockUserDao).insertUser(testUser); + } + } + + @Test + @DisplayName("callback should throw BadRequestException when token validation returns null") + void callback_throwBadRequestException_whenTokenValidationFails() throws Exception { + try (MockedStatic jwtUtilsMock = mockStatic(JwtUtils.class); + MockedStatic networkUtilsMock = mockStatic(NetworkUtils.class)) { + + jwtUtilsMock.when(() -> JwtUtils.validateTokenAgainstKeys("invalid_token")).thenReturn(null); + setupMockedValidOpenIDConfig(networkUtilsMock); + + assertThrows(BadRequestException.class, () -> AuthenticationService.callback("invalid_token")); + } + } + + @Test + @DisplayName("callback should throw InternalErrorException when OpenID returned is suspicious") + void callback_throwInternalErrorException_whenOpenIDCacheLooksSuspicious() throws Exception { + try (MockedStatic daoServiceMock = mockStatic(DaoService.class); + MockedStatic jwtUtilsMock = mockStatic(JwtUtils.class); + MockedStatic networkUtilsMock = mockStatic(NetworkUtils.class); + MockedStatic appPropsMock = mockStatic(ApplicationProperties.class)) { + + daoServiceMock.when(DaoService::getUserDao).thenReturn(mockUserDao); + when(mockUserDao.getUser("test_netid")).thenReturn(null); + jwtUtilsMock.when(() -> JwtUtils.validateTokenAgainstKeys("valid_token")).thenReturn("test_netid"); + + appPropsMock.when(ApplicationProperties::useCanvas).thenReturn(false); + + setupSuspiciousValidOpenIDConfig(networkUtilsMock); + + Assertions.assertThrows(InternalServerException.class, + ()-> AuthenticationService.callback("valid_token")); + + verify(mockUserDao, times(0)).insertUser(testUser); + } + } + + // ==================== isSecure() Tests ==================== + + @Test + @DisplayName("isSecure should return true when frontend URL starts with https") + void isSecure_returnTrue_whenFrontendUrlIsHttps() { + try (MockedStatic appPropsMock = mockStatic(ApplicationProperties.class)) { + appPropsMock.when(ApplicationProperties::frontendUrl).thenReturn("https://example.com"); + + assertTrue(AuthenticationService.isSecure()); + } + } + + @Test + @DisplayName("isSecure should return false when frontend URL does not start with https") + void isSecure_returnFalse_whenFrontendUrlIsNotHttps() { + try (MockedStatic appPropsMock = mockStatic(ApplicationProperties.class)) { + appPropsMock.when(ApplicationProperties::frontendUrl).thenReturn("http://example.com"); + + assertFalse(AuthenticationService.isSecure()); + } + } + + // ==================== Helper Methods ==================== + + private void setupMockedValidOpenIDConfig(MockedStatic networkUtilsMock) throws IOException, InterruptedException { + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.body()).thenReturn(createValidOpenIDConfigJson()); + networkUtilsMock.when(() -> NetworkUtils.makeJsonGetRequest(anyString())) + .thenReturn(mockResponse); + networkUtilsMock.when(() -> NetworkUtils.getCacheTime(any())) + .thenReturn(Instant.now().plusSeconds(3600)); + } + + private void setupSuspiciousValidOpenIDConfig(MockedStatic networkUtilsMock){ + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.body()).thenReturn(createBadIssuerOpenIDConfigJson()); + networkUtilsMock.when(() -> NetworkUtils.makeJsonGetRequest(anyString())) + .thenReturn(mockResponse); + networkUtilsMock.when(() -> NetworkUtils.getCacheTime(any())) + .thenReturn(Instant.now().plusSeconds(0)); + } + + private String createValidOpenIDConfigJson() { + AuthenticationService.OpenIDConfig config = new AuthenticationService.OpenIDConfig( + AuthenticationService.BYU_API_URL, + "https://api-sandbox.byu.edu/auth", + "https://api-sandbox.byu.edu/token", + "https://api-sandbox.byu.edu/jwks", + List.of("openid"), + List.of("RS256") + ); + return new Gson().toJson(config); + } + + private String createBadIssuerOpenIDConfigJson(){ + AuthenticationService.OpenIDConfig config = new AuthenticationService.OpenIDConfig( + "https://badactor.com", + "https://api-sandbox.byu.edu/auth", + "https://api-sandbox.byu.edu/token", + "https://api-sandbox.byu.edu/jwks", + List.of("openid"), + List.of("RS256") + ); + return new Gson().toJson(config); + } +} From f3007029a38bbd24f5633a54668e9c06919386e7 Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 13 Jan 2026 19:45:51 -0700 Subject: [PATCH 18/34] add test for authorization url --- .../service/AuthenticationServiceTests.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java b/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java index 1cc2d4b36..4dd243da6 100644 --- a/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java +++ b/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java @@ -16,7 +16,9 @@ import org.mockito.MockedStatic; import java.io.IOException; +import java.net.URLEncoder; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.List; @@ -146,6 +148,29 @@ void isSecure_returnFalse_whenFrontendUrlIsNotHttps() { } } + // ==================== Authorization Url Test ============ + + @Test + @DisplayName("getAuthorizationUrl returns a filled out authorization request") + void getAuthUrl() throws Exception{ + try (MockedStatic networkUtilsMock = mockStatic(NetworkUtils.class); + MockedStatic appPropsMock = mockStatic(ApplicationProperties.class)) { + + + appPropsMock.when(ApplicationProperties::casCallbackUrl).thenReturn("https://cs240.click/auth/callback"); + appPropsMock.when(ApplicationProperties::clientId).thenReturn("cs240"); + setupMockedValidOpenIDConfig(networkUtilsMock); + + Assertions.assertEquals("https://api-sandbox.byu.edu/auth?response_type=code" + + "&client_id=cs240" + + "&redirect_uri=" + + URLEncoder.encode("https://cs240.click/auth/callback", StandardCharsets.UTF_8) + + "&scope=openid", + AuthenticationService.getAuthorizationUrl()); + + } + } + // ==================== Helper Methods ==================== private void setupMockedValidOpenIDConfig(MockedStatic networkUtilsMock) throws IOException, InterruptedException { From 4de5a3447f8d9986378cf04d6451f45a8c05188b Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 13 Jan 2026 19:48:48 -0700 Subject: [PATCH 19/34] fix not checking if cache time was above 0 --- src/main/java/edu/byu/cs/service/AuthenticationService.java | 2 -- src/main/java/edu/byu/cs/util/NetworkUtils.java | 5 ++++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index d70c619c7..7f543d9ac 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -143,8 +143,6 @@ public record TokenResponse( ) {} - //FIXME: make sure cache control is still valid - /** * Caches the info from the api needed to complete the OAuth transaction. * @throws InternalServerException when there is something suspicious about the OpenID config diff --git a/src/main/java/edu/byu/cs/util/NetworkUtils.java b/src/main/java/edu/byu/cs/util/NetworkUtils.java index 0e15f20b6..2ed06b3fd 100644 --- a/src/main/java/edu/byu/cs/util/NetworkUtils.java +++ b/src/main/java/edu/byu/cs/util/NetworkUtils.java @@ -93,7 +93,10 @@ public static Instant getCacheTime(HttpResponse response) throws Interna Optional cache = response.headers().firstValue("Cache-Control"); try{ String seconds = cache.get().replace("max-age=", ""); - return Instant.now().plusSeconds(Long.parseLong(seconds)); + if (Long.parseLong(seconds) > 0){ + return Instant.now().plusSeconds(Long.parseLong(seconds)); + } + else throw new InternalServerException("Invalid cache time", new IllegalArgumentException()); } catch (NoSuchElementException e) { throw new InternalServerException("Unable to determine cache time", e); From b1633026f17d96e50ea65e0c9f051918fceb1f7d Mon Sep 17 00:00:00 2001 From: mewilker Date: Fri, 16 Jan 2026 12:56:21 -0700 Subject: [PATCH 20/34] create key pair helper functions for testing --- .../java/edu/byu/cs/util/JwtUtilsTest.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/test/java/edu/byu/cs/util/JwtUtilsTest.java b/src/test/java/edu/byu/cs/util/JwtUtilsTest.java index dd406cc42..3b001d40a 100644 --- a/src/test/java/edu/byu/cs/util/JwtUtilsTest.java +++ b/src/test/java/edu/byu/cs/util/JwtUtilsTest.java @@ -1,11 +1,17 @@ package edu.byu.cs.util; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.impl.security.DefaultJwkSet; +import io.jsonwebtoken.security.*; import org.junit.jupiter.api.Test; +import java.security.*; +import java.security.KeyPair; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; import static org.junit.jupiter.api.Assertions.*; @@ -41,6 +47,36 @@ void validateToken__expired() { assertNull(JwtUtils.validateToken(token)); } + @Test + void validateTokenAgainstKeys() { + + } + + private HashMap generateKeyPairs(int size) throws NoSuchAlgorithmException { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(256); + HashMap pairs = new HashMap<>(); + for (int i = 0; i < size; i++){ + KeyPair pair = generator.generateKeyPair(); + pairs.put(i, pair); + } + return pairs; + } + + private JwkSet generateJwks(HashMap pairs) { + HashSet> set = new HashSet<>(); + for (int i = 0; i < pairs.size(); i++){ + KeyPair pair = pairs.get(i); + Jwk jwk = Jwks.builder() + .key(pair.getPublic()) + .id(Integer.toString(i)) + .build(); + set.add(jwk); + } + JwkSet jwks = Jwks.set().add(set).build(); + return jwks; + } + private String generateToken(boolean expired) { Instant expiration = expired ? Instant.now().minus(1, ChronoUnit.HOURS) @@ -50,4 +86,5 @@ private String generateToken(boolean expired) { .expiration(Date.from(expiration)) .compact(); } + } From 426d5cc955efd399cc44c849b5be9776fec100fa Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 19 Jan 2026 18:05:51 -0700 Subject: [PATCH 21/34] fix readJWKs to not return anything --- src/main/java/edu/byu/cs/util/JwtUtils.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/byu/cs/util/JwtUtils.java b/src/main/java/edu/byu/cs/util/JwtUtils.java index 596d4f7fc..6d770cf0b 100644 --- a/src/main/java/edu/byu/cs/util/JwtUtils.java +++ b/src/main/java/edu/byu/cs/util/JwtUtils.java @@ -103,12 +103,10 @@ public static String validateTokenAgainstKeys(String token){ return netid; } - public static JwkSet readJWKs(String json){ - var keys = Jwks.setParser() + public static void readJWKs(String json){ + byuPublicKeys = Jwks.setParser() .build() .parse(json); - byuPublicKeys = keys; - return keys; } static Locator locator = new Locator<>() { From 0dfe75406ca3442b54da1dbd6fe0e09c88e34ce8 Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 19 Jan 2026 18:13:31 -0700 Subject: [PATCH 22/34] add helper function to generate token with given key --- src/test/java/edu/byu/cs/util/JwtUtilsTest.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/byu/cs/util/JwtUtilsTest.java b/src/test/java/edu/byu/cs/util/JwtUtilsTest.java index 3b001d40a..c3c78e208 100644 --- a/src/test/java/edu/byu/cs/util/JwtUtilsTest.java +++ b/src/test/java/edu/byu/cs/util/JwtUtilsTest.java @@ -1,9 +1,10 @@ package edu.byu.cs.util; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.impl.security.DefaultJwkSet; import io.jsonwebtoken.security.*; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.security.*; import java.security.KeyPair; @@ -47,8 +48,11 @@ void validateToken__expired() { assertNull(JwtUtils.validateToken(token)); } - @Test - void validateTokenAgainstKeys() { + @ParameterizedTest + @ValueSource(ints = {1, 2, 3}) + void validateTokenAgainstKeys(int size) throws Exception{ + HashMap map = generateKeyPairs(size); + JwkSet set = generateJwks(map); } @@ -87,4 +91,11 @@ private String generateToken(boolean expired) { .compact(); } + private String generateToken(PrivateKey key){ + return Jwts.builder() + .subject("testNetId") + .signWith(key) + .compact(); + } + } From 550ae2c143a0577df35e1b9be5e176583dd9f6d7 Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 19 Jan 2026 19:33:29 -0700 Subject: [PATCH 23/34] fix static issues introduced within JwtUtils --- src/main/java/edu/byu/cs/util/JwtUtils.java | 35 +++++---------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/src/main/java/edu/byu/cs/util/JwtUtils.java b/src/main/java/edu/byu/cs/util/JwtUtils.java index 6d770cf0b..14fea3c21 100644 --- a/src/main/java/edu/byu/cs/util/JwtUtils.java +++ b/src/main/java/edu/byu/cs/util/JwtUtils.java @@ -77,29 +77,21 @@ private static SecretKey generateSecretKey() { } public static String validateTokenAgainstKeys(String token){ - String netid = null; - //one key short circuit - if (byuPublicKeys.size() == 1){ - var key = byuPublicKeys.getKeys().stream().findFirst().get().toKey(); - try { - netid = Jwts.parser() - .verifyWith((PublicKey) key) - .build() - .parseSignedClaims(token) - .getPayload() - .getSubject(); - } catch (Exception e) { - LOGGER.error("Unable to verify with JWK:",e); + Locator locator = header -> { + for (Jwk key : byuPublicKeys) { + if (((ProtectedHeader) header).getKeyId().equals(key.getId())) { + return key.toKey(); + } } - } - else{ + return null; + }; + String netid = null; netid = Jwts.parser() .keyLocator(locator) .build() .parseSignedClaims(token) .getPayload() .getSubject(); - } return netid; } @@ -109,16 +101,5 @@ public static void readJWKs(String json){ .parse(json); } - static Locator locator = new Locator<>() { - @Override - public Key locate(Header header) { - for (Jwk key : byuPublicKeys) { - if (((ProtectedHeader) header).getKeyId().equals(key.getId())) { - return key.toKey(); - } - } - return null; - } - }; } From 580a62baadd241b26fd2606a1690fc3616baf5c0 Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 19 Jan 2026 19:42:54 -0700 Subject: [PATCH 24/34] add unit tests to JwtUtils for multiple keys --- .../java/edu/byu/cs/util/JwtUtilsTest.java | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/src/test/java/edu/byu/cs/util/JwtUtilsTest.java b/src/test/java/edu/byu/cs/util/JwtUtilsTest.java index c3c78e208..082d08905 100644 --- a/src/test/java/edu/byu/cs/util/JwtUtilsTest.java +++ b/src/test/java/edu/byu/cs/util/JwtUtilsTest.java @@ -1,13 +1,16 @@ package edu.byu.cs.util; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.jackson.io.JacksonSerializer; import io.jsonwebtoken.security.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import java.nio.charset.StandardCharsets; import java.security.*; import java.security.KeyPair; +import io.jsonwebtoken.security.SignatureException; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; @@ -48,17 +51,38 @@ void validateToken__expired() { assertNull(JwtUtils.validateToken(token)); } - @ParameterizedTest + @ParameterizedTest(name = "validateTokenAgainst{0}Keys") @ValueSource(ints = {1, 2, 3}) void validateTokenAgainstKeys(int size) throws Exception{ HashMap map = generateKeyPairs(size); JwkSet set = generateJwks(map); + String token = generateToken(map.get(size-1).getPrivate(), size-1); + byte[] bytes = new JacksonSerializer().serialize(set); + String serialized = new String(bytes, StandardCharsets.UTF_8); + JwtUtils.readJWKs(serialized); + String netId = JwtUtils.validateTokenAgainstKeys(token); + assertEquals("testNetId", netId); + } + + @Test + void invalidTokenNotVerifiedByAnyKey() throws Exception{ + HashMap map = generateKeyPairs(3); + JwkSet set = generateJwks(map); + + //sign with a fake key + KeyPair fake = generateKeyPairs(1).get(0); + + String token = generateToken(fake.getPrivate(), 2); + byte[] bytes = new JacksonSerializer().serialize(set); + String serialized = new String(bytes, StandardCharsets.UTF_8); + JwtUtils.readJWKs(serialized); + assertThrows(SignatureException.class, ()-> JwtUtils.validateTokenAgainstKeys(token)); } private HashMap generateKeyPairs(int size) throws NoSuchAlgorithmException { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); - generator.initialize(256); + generator.initialize(2048); HashMap pairs = new HashMap<>(); for (int i = 0; i < size; i++){ KeyPair pair = generator.generateKeyPair(); @@ -91,8 +115,11 @@ private String generateToken(boolean expired) { .compact(); } - private String generateToken(PrivateKey key){ + private String generateToken(PrivateKey key, int id){ return Jwts.builder() + .header() + .keyId(Integer.toString(id)) + .and() .subject("testNetId") .signWith(key) .compact(); From 68182dde67d34b47deffc363b007f0db628ce737 Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 19 Jan 2026 19:44:46 -0700 Subject: [PATCH 25/34] fix imports and redundant variables --- src/main/java/edu/byu/cs/util/JwtUtils.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/edu/byu/cs/util/JwtUtils.java b/src/main/java/edu/byu/cs/util/JwtUtils.java index 14fea3c21..6e46169d9 100644 --- a/src/main/java/edu/byu/cs/util/JwtUtils.java +++ b/src/main/java/edu/byu/cs/util/JwtUtils.java @@ -11,7 +11,6 @@ import javax.crypto.SecretKey; import java.security.Key; import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -85,7 +84,7 @@ public static String validateTokenAgainstKeys(String token){ } return null; }; - String netid = null; + String netid; netid = Jwts.parser() .keyLocator(locator) .build() From 930ef7c24ee89a2683b508a43ffb16af6712a59e Mon Sep 17 00:00:00 2001 From: mewilker Date: Tue, 20 Jan 2026 17:22:59 -0700 Subject: [PATCH 26/34] remove commented old code --- src/main/java/edu/byu/cs/controller/RedirectController.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/edu/byu/cs/controller/RedirectController.java b/src/main/java/edu/byu/cs/controller/RedirectController.java index 1d0d47a22..0eafbef73 100644 --- a/src/main/java/edu/byu/cs/controller/RedirectController.java +++ b/src/main/java/edu/byu/cs/controller/RedirectController.java @@ -29,8 +29,6 @@ public class RedirectController { } AuthenticationService.TokenResponse response = AuthenticationService.exchangeCodeForTokens(code); - //String ticket = ctx.queryParam("ticket"); - User user; try { user = AuthenticationService.callback(response.idToken()); From 89fa61a55bd97cf84731f42e2c9863a277f01d2f Mon Sep 17 00:00:00 2001 From: mewilker Date: Thu, 22 Jan 2026 15:33:10 -0700 Subject: [PATCH 27/34] specify that a sandbox client id is needed --- docs/getting-started/getting-started.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/getting-started/getting-started.md b/docs/getting-started/getting-started.md index f4ec01f27..9f254d0be 100644 --- a/docs/getting-started/getting-started.md +++ b/docs/getting-started/getting-started.md @@ -111,6 +111,7 @@ Do the following actions: --use-canvas false #Follow the steps at https://developer.byu.edu/data/api-usage/create-an-oauth-client +#Please choose the sandbox environment #You are going to want to choose the Auth Code + PKCE option #For the redirect url, you should use the cas-callback-url --client-id From 4ce2e3302a2d0da4f08c358b40f83fa89f680f2c Mon Sep 17 00:00:00 2001 From: mewilker Date: Thu, 22 Jan 2026 15:35:20 -0700 Subject: [PATCH 28/34] change netId to follow naming conventions --- src/main/java/edu/byu/cs/util/JwtUtils.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/edu/byu/cs/util/JwtUtils.java b/src/main/java/edu/byu/cs/util/JwtUtils.java index 6e46169d9..e0403e80d 100644 --- a/src/main/java/edu/byu/cs/util/JwtUtils.java +++ b/src/main/java/edu/byu/cs/util/JwtUtils.java @@ -84,14 +84,14 @@ public static String validateTokenAgainstKeys(String token){ } return null; }; - String netid; - netid = Jwts.parser() + String netId; + netId = Jwts.parser() .keyLocator(locator) .build() .parseSignedClaims(token) .getPayload() .getSubject(); - return netid; + return netId; } public static void readJWKs(String json){ From ba5609b19f716e81bf9cea647b162bd8db7817da Mon Sep 17 00:00:00 2001 From: mewilker Date: Thu, 22 Jan 2026 15:37:44 -0700 Subject: [PATCH 29/34] change locator logic for readability --- src/main/java/edu/byu/cs/util/JwtUtils.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/edu/byu/cs/util/JwtUtils.java b/src/main/java/edu/byu/cs/util/JwtUtils.java index e0403e80d..8e65ea8c2 100644 --- a/src/main/java/edu/byu/cs/util/JwtUtils.java +++ b/src/main/java/edu/byu/cs/util/JwtUtils.java @@ -78,8 +78,10 @@ private static SecretKey generateSecretKey() { public static String validateTokenAgainstKeys(String token){ Locator locator = header -> { for (Jwk key : byuPublicKeys) { - if (((ProtectedHeader) header).getKeyId().equals(key.getId())) { - return key.toKey(); + if (header instanceof ProtectedHeader protectedHeader) { + if (protectedHeader.getKeyId().equals(key.getId())) { + return key.toKey(); + } } } return null; From 155805efee1d8fcbfa880bc001bab5a85de8fffc Mon Sep 17 00:00:00 2001 From: mewilker Date: Thu, 22 Jan 2026 15:39:21 -0700 Subject: [PATCH 30/34] fix difference between class and file name --- .../java/edu/byu/cs/service/AuthenticationServiceTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java b/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java index 4dd243da6..f0a59761d 100644 --- a/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java +++ b/src/test/java/edu/byu/cs/service/AuthenticationServiceTests.java @@ -26,7 +26,7 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -class AuthenticationServiceTest { +class AuthenticationServiceTests { private UserDao mockUserDao; private User testUser; From fb9ef714fac5b1b378c8f726f4d7e8ddfd34cf51 Mon Sep 17 00:00:00 2001 From: mewilker Date: Thu, 22 Jan 2026 15:40:32 -0700 Subject: [PATCH 31/34] documentation grammar fix --- src/main/java/edu/byu/cs/service/AuthenticationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index 7f543d9ac..ea2912e06 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -30,7 +30,7 @@ /** * Contains service logic for the {@link RedirectController}.
View the * BYU API documentation - * to understand OAuth works, if needed. Other sites on this page are a great resource as well, + * to understand how OAuth works, if needed. Other sites on this page are a great resource as well, * particularly verifying JWT tokens. You should also check out the {@link JwtUtils} class as well. *

* The {@code AuthenticationService} ensures the user authenticates before they access From de15ffc474b86f3e533c99ab165403e365e4f08f Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 26 Jan 2026 19:48:01 -0700 Subject: [PATCH 32/34] fix key parser redundant variable --- src/main/java/edu/byu/cs/util/JwtUtils.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/edu/byu/cs/util/JwtUtils.java b/src/main/java/edu/byu/cs/util/JwtUtils.java index 8e65ea8c2..33e01504a 100644 --- a/src/main/java/edu/byu/cs/util/JwtUtils.java +++ b/src/main/java/edu/byu/cs/util/JwtUtils.java @@ -86,14 +86,12 @@ public static String validateTokenAgainstKeys(String token){ } return null; }; - String netId; - netId = Jwts.parser() + return Jwts.parser() .keyLocator(locator) .build() .parseSignedClaims(token) .getPayload() .getSubject(); - return netId; } public static void readJWKs(String json){ From 61f96ad227643b1a2c9c70c5d447d03cc7eafa88 Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 26 Jan 2026 19:57:23 -0700 Subject: [PATCH 33/34] create refresh config function --- .../edu/byu/cs/service/AuthenticationService.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index ea2912e06..b908e144e 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -107,9 +107,7 @@ public static User callback(String ticket) throws InternalServerException, BadRe */ public static String validateToken(String token) throws InternalServerException, IOException, InterruptedException { if (isExpired(keyExpiration)){ - if (isExpired(configExpiration)){ - cacheBYUOpenIDConfig(); - } + refreshConfig(); cacheJWK(); } @@ -117,9 +115,7 @@ public static String validateToken(String token) throws InternalServerException, } public static TokenResponse exchangeCodeForTokens(String code) throws IOException, InterruptedException, InternalServerException { - if (isExpired(configExpiration)){ - cacheBYUOpenIDConfig(); - } + refreshConfig(); String formData = "grant_type=authorization_code" + "&client_id=" + URLEncoder.encode(ApplicationProperties.clientId(), StandardCharsets.UTF_8) + @@ -133,6 +129,12 @@ public static TokenResponse exchangeCodeForTokens(String code) throws IOExceptio } + private static void refreshConfig() throws InternalServerException, IOException, InterruptedException { + if (isExpired(configExpiration)){ + cacheBYUOpenIDConfig(); + } + } + public record TokenResponse( @SerializedName("access_token") String accessToken, From 26b6b1a031e002c3e3efb6a948b6d14b2d2b28cb Mon Sep 17 00:00:00 2001 From: mewilker Date: Mon, 26 Jan 2026 20:00:35 -0700 Subject: [PATCH 34/34] invert if else logic in cacheBYUOpenIDConfig --- .../java/edu/byu/cs/service/AuthenticationService.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/edu/byu/cs/service/AuthenticationService.java b/src/main/java/edu/byu/cs/service/AuthenticationService.java index b908e144e..1f8a2613e 100644 --- a/src/main/java/edu/byu/cs/service/AuthenticationService.java +++ b/src/main/java/edu/byu/cs/service/AuthenticationService.java @@ -156,13 +156,12 @@ private static void cacheBYUOpenIDConfig() throws InternalServerException, IOExc configExpiration = NetworkUtils.getCacheTime(response); OpenIDConfig config = new Gson().fromJson(response.body(), OpenIDConfig.class); - if (isValidConfig(config)){ - AuthenticationService.config = config; - } - else { + if (!isValidConfig(config)){ throw new InternalServerException("Unable to verify OpenID config", null); } + AuthenticationService.config = config; + } /**