PatCredentialController.java
package edu.ucsb.cs.scaffold.controller;
import edu.ucsb.cs.scaffold.entity.PatCredential;
import edu.ucsb.cs.scaffold.enums.PatPlatform;
import edu.ucsb.cs.scaffold.errors.EntityNotFoundException;
import edu.ucsb.cs.scaffold.repository.PatCredentialRepository;
import edu.ucsb.cs.scaffold.services.PatEncryptionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.LocalDate;
import java.util.Map;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* Lets each admin or instructor store one personal access token (PAT) per platform: a GitHub PAT at
* /api/pat/github and a PrairieLearn PAT at /api/pat/pl. Tokens are encrypted before they are saved
* and are write-only: no endpoint ever returns one, only metadata (last four characters, expiration
* date). To replace a lost or expired token, the user simply POSTs a new one. See
* docs/Github_PAT.md and docs/PrairieLearn_PAT.md for how to create suitable tokens.
*/
@Tag(name = "PatCredential")
@RequestMapping("/api/pat")
@RestController
@Slf4j
public class PatCredentialController extends ApiController {
// A GitHub classic PAT: "ghp_" followed by letters and digits. Only classic tokens work here:
// a fine-grained token can only be scoped to repos owned by the user's own account or an org
// the user is a member of, and this app's users are outside collaborators on repos owned by
// another org — so a fine-grained token can never reach them. See docs/PAT-design.md.
private static final Pattern CLASSIC_PAT_PATTERN = Pattern.compile("^ghp_[A-Za-z0-9]{20,244}$");
@Autowired private PatCredentialRepository patCredentialRepository;
@Autowired private PatEncryptionService patEncryptionService;
@Operation(
summary = "Get metadata about the current user's stored GitHub PAT (never the token itself)")
@PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
@GetMapping("/github")
public PatCredential getGithubPatCredential() {
return getPatCredential(PatPlatform.GITHUB);
}
@Operation(
summary =
"Get metadata about the current user's stored PrairieLearn PAT (never the token itself)")
@PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
@GetMapping("/pl")
public PatCredential getPlPatCredential() {
return getPatCredential(PatPlatform.PRAIRIELEARN);
}
@Operation(summary = "Set (create or replace) the current user's GitHub PAT")
@PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
@PostMapping("/github")
public PatCredential postGithubPatCredential(
@Parameter(name = "token", description = "GitHub classic PAT (starts with ghp_)")
@RequestParam
String token,
@Parameter(
name = "expiresAt",
description = "Expiration date of the token in ISO format, e.g. 2026-12-31")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate expiresAt) {
String trimmedToken = token.strip();
validateGithubToken(trimmedToken);
return savePatCredential(PatPlatform.GITHUB, trimmedToken, expiresAt);
}
@Operation(summary = "Set (create or replace) the current user's PrairieLearn PAT")
@PreAuthorize("hasRole('ROLE_ADMIN') || hasRole('ROLE_INSTRUCTOR')")
@PostMapping("/pl")
public PatCredential postPlPatCredential(
@Parameter(name = "token", description = "PrairieLearn personal access token") @RequestParam
String token,
@Parameter(
name = "expiresAt",
description = "Expiration date of the token in ISO format, e.g. 2026-12-31")
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate expiresAt) {
String trimmedToken = token.strip();
if (trimmedToken.isBlank()) {
throw new IllegalArgumentException("token is required");
}
return savePatCredential(PatPlatform.PRAIRIELEARN, trimmedToken, expiresAt);
}
private PatCredential getPatCredential(PatPlatform platform) {
long userId = getCurrentUser().getUser().getId();
return patCredentialRepository
.findByUserIdAndPlatform(userId, platform)
.orElseThrow(() -> new EntityNotFoundException(PatCredential.class, userId));
}
private PatCredential savePatCredential(
PatPlatform platform, String trimmedToken, LocalDate expiresAt) {
PatEncryptionService.EncryptedPat encrypted = patEncryptionService.encrypt(trimmedToken);
long userId = getCurrentUser().getUser().getId();
PatCredential credential =
patCredentialRepository
.findByUserIdAndPlatform(userId, platform)
.orElseGet(() -> PatCredential.builder().userId(userId).platform(platform).build());
credential.setCiphertext(encrypted.ciphertext());
credential.setKeyVersion(encrypted.keyVersion());
credential.setLastFour(trimmedToken.substring(trimmedToken.length() - 4));
credential.setExpiresAt(expiresAt);
return patCredentialRepository.save(credential);
}
private void validateGithubToken(String token) {
if (token.isBlank()) {
throw new IllegalArgumentException("token is required");
}
if (token.startsWith("github_pat_")) {
throw new IllegalArgumentException(
"fine-grained tokens (github_pat_...) cannot reach this app's repositories; create a classic token (ghp_...) instead — see docs/Github_PAT.md");
}
if (!CLASSIC_PAT_PATTERN.matcher(token).matches()) {
throw new IllegalArgumentException(
"token must be a GitHub classic personal access token (starting with ghp_); see docs/Github_PAT.md");
}
}
/**
* Thrown by PatEncryptionService when no PAT_ENCRYPTION_KEY is configured — a server-side
* deployment problem, not a client error, so it maps to 503 rather than 400.
*/
@ExceptionHandler({IllegalStateException.class})
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public Object handleIllegalStateException(Throwable e) {
return Map.of(
"type", e.getClass().getSimpleName(),
"message", e.getMessage());
}
}