Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ db:
auth:
jwt:
secret: secret # Change this to a long random string
access_token_duration: 15m
refresh_token_duration: 48h
access_token_duration: 1h
refresh_token_duration: 168h
session_max_duration: 336h
admins:
- mharold@udallas.edu
33 changes: 28 additions & 5 deletions handler/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -419,22 +419,45 @@ func (router *HttpRouter) RefreshToken(c echo.Context) error {
return c.JSON(http.StatusUnauthorized, json_response.NewError("unauthorized"))
}

accessTokenDetails, err := router.app.RefreshToken(refreshTokenString)
tokenDetails, err := router.app.RefreshToken(refreshTokenString)
if err != nil {
logger.Error("failed to refresh the access token", zap.Error(err))
return c.JSON(http.StatusInternalServerError, "failed to refresh the access token")
return c.JSON(http.StatusUnauthorized, json_response.NewError("failed to refresh token"))
}

c.SetCookie(&http.Cookie{
Name: "access_token",
Value: accessTokenDetails.TokenString,
Value: tokenDetails.AccessToken.TokenString,
Path: "/",
Expires: accessTokenDetails.ExpiresAt,
Expires: tokenDetails.AccessToken.ExpiresAt,
Secure: true,
HttpOnly: true,
})
c.SetCookie(&http.Cookie{
Name: "refresh_token",
Value: tokenDetails.RefreshToken.TokenString,
Path: "/",
Expires: tokenDetails.RefreshToken.ExpiresAt,
Secure: true,
HttpOnly: true,
})

return c.JSON(http.StatusOK, json_response.NewMessage("token refreshed"))
if sessionIDCookie, cookieErr := c.Cookie("session_id"); cookieErr == nil {
c.SetCookie(&http.Cookie{
Name: "session_id",
Value: sessionIDCookie.Value,
Path: "/",
Expires: tokenDetails.RefreshToken.ExpiresAt,
Secure: true,
HttpOnly: true,
})
}

return c.JSON(http.StatusOK, echo.Map{
"message": "token refreshed",
"access_expires_at": tokenDetails.AccessToken.ExpiresAt.UnixMilli(),
"refresh_expires_at": tokenDetails.RefreshToken.ExpiresAt.UnixMilli(),
})
}

func (router *HttpRouter) GetClassroomsOfUser(c echo.Context) error {
Expand Down
8 changes: 8 additions & 0 deletions repository/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ func (store PostgresStore) DeleteSession(sessionId uuid.UUID) error {
return err
}

func (store PostgresStore) UpdateSession(sessionId uuid.UUID, tokenHash string, expiresAt time.Time) error {
_, err := store.db.Exec(
"UPDATE sessions SET token_hash = $2, expires_at = $3 WHERE id = $1;",
sessionId, tokenHash, expiresAt,
)
return err
}

func (store PostgresStore) GetClassroomsOfUser(userEmail string) ([]models.Classroom, error) {
user_info, err := store.GetUserInfo(userEmail)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions repository/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ type Datastore interface {
CreateSession(session models.Session) (*models.Session, error)
DeleteSession(sessionId uuid.UUID) error
GetSession(userEmail string, refreshTokenString string) (*models.Session, error)
UpdateSession(sessionId uuid.UUID, tokenHash string, expiresAt time.Time) error
GetClassroomsOfUser(userEmail string) ([]models.Classroom, error)
ChangeUserInfo(request models.ChangeUserInfoRequest) error
}
Expand Down
17 changes: 10 additions & 7 deletions service/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ func (app *GraderApp) PasswordReset(details models.NewPasswordDetails, session m
return tokenDetails, nil
}

func (app *GraderApp) RefreshToken(refreshTokenString string) (*models.AccessToken, error) {
func (app *GraderApp) RefreshToken(refreshTokenString string) (*models.JWTTokens, error) {
refreshTokenClaims, err := jwt_token.ParseRefreshTokenString(refreshTokenString, app.authConfig.JWT.Secret)
if err != nil {
return nil, fmt.Errorf("invalid autorization credentials")
Expand All @@ -360,17 +360,20 @@ func (app *GraderApp) RefreshToken(refreshTokenString string) (*models.AccessTok
return nil, fmt.Errorf("invalid refresh token")
}

accessTokenString, accessTokenExpiration, err := jwt_token.CreateAccessTokenString(session.UserEmail, session.UserRole, app.authConfig.JWT.AccessTokenDuration, app.authConfig.JWT.Secret)
if app.authConfig.JWT.SessionMaxDuration > 0 && time.Now().After(session.CreatedAt.Add(app.authConfig.JWT.SessionMaxDuration)) {
return nil, fmt.Errorf("session exceeded max duration")
}

tokenDetails, err := jwt_token.CreateJWTTokens(session.UserEmail, session.UserRole, app.authConfig.JWT)
if err != nil {
return nil, fmt.Errorf("failed to create access token: %v", err)
return nil, fmt.Errorf("failed to create JWT tokens: %v", err)
}

accessToken := &models.AccessToken{
TokenString: accessTokenString,
ExpiresAt: accessTokenExpiration,
if err := app.store.UpdateSession(session.Id, token.HashToken(tokenDetails.RefreshToken.TokenString), tokenDetails.RefreshToken.ExpiresAt); err != nil {
return nil, fmt.Errorf("failed to update session: %v", err)
}

return accessToken, nil
return tokenDetails, nil
}

func (app *GraderApp) GetClassroomsOfUser(jwksToken string) ([]models.Classroom, error) {
Expand Down
2 changes: 1 addition & 1 deletion service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type App interface {
PasswordResetRequest(jwksToken string) error
PasswordResetRequestByEmail(userEmail string) error
PasswordReset(details models.NewPasswordDetails, session models.Session) (*models.JWTTokens, error)
RefreshToken(tokenString string) (*models.AccessToken, error)
RefreshToken(tokenString string) (*models.JWTTokens, error)
IsValidLogin(jwksToken string) bool
ValidInvite(inviteId uuid.UUID, token string) bool
ValidPasswordReset(requestId uuid.UUID, tokenString string) bool
Expand Down
2 changes: 2 additions & 0 deletions utils/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type (
Secret string
AccessTokenDuration time.Duration `mapstructure:"access_token_duration"`
RefreshTokenDuration time.Duration `mapstructure:"refresh_token_duration"`
SessionMaxDuration time.Duration `mapstructure:"session_max_duration"`
}
)

Expand All @@ -59,6 +60,7 @@ func GetConfig() *Config {
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.SetDefault("app.timezone", "America/Chicago")
viper.SetDefault("auth.jwt.session_max_duration", "336h")

if err := viper.ReadInConfig(); err != nil {
panic(err)
Expand Down
5 changes: 0 additions & 5 deletions utils/middlewares/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package middlewares

import (
"fmt"
"time"

"github.com/labstack/echo/v4"
)
Expand All @@ -14,10 +13,6 @@ func ParseCookieForToken(c echo.Context, tokenName string) (tokenString string,
return "", fmt.Errorf("could not find `%s` cookie: %v", tokenName, err)
}

if cookie.Expires.After(time.Now()) {
return "", fmt.Errorf("expired authentication credentials")
}

return cookie.Value, nil
}

Expand Down
16 changes: 16 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ export default tseslint.config({

- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`

## Authentication & API requests

- **Token policy:** Access tokens are short-lived (`1h`), refresh tokens are longer-lived (`7d`), and server-side sessions enforce an absolute max window (`14d`).
- **Client behavior:** The frontend calls `/api/auth/refresh` on protected page load, retries once on `401`, and schedules proactive refreshes before access-token expiry.
- **Refresh rotation:** Each successful refresh rotates the refresh token and slides session expiry forward.
- **Fetch wrapper:** Use the `fetchWithAuth` helper in `web/src/utils/fetcher.ts` for authenticated API calls. It sets `credentials: 'include'` and handles refresh-on-`401`.
- **CORS / cookies:** When running backend and frontend on different origins, ensure the server allows credentials and the frontend origin (for example `Access-Control-Allow-Credentials: true` and `Access-Control-Allow-Origin: <frontend-origin>`).

Manual test steps:

1. Start backend and frontend, then log in.
2. In DevTools, verify `access_token`, `refresh_token`, and `session_id` cookies are present.
3. Keep a protected page open and active. Around access-token expiry, the client should refresh in the background without forcing a reload.
4. Trigger an authenticated API call after access-token expiry and confirm the request succeeds after a transparent refresh+retry.
5. Verify logout still clears all auth cookies and redirects to login.
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:

```js
Expand Down
4 changes: 3 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "tsc && vite build --watch",
"build": "tsc -b && vite build",
"lint": "eslint .",
"test": "vitest run",
"preview": "vite preview"
},
"dependencies": {
Expand All @@ -32,6 +33,7 @@
"globals": "^15.9.0",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^5.4.1"
"vite": "^5.4.1",
"vitest": "^1.2.0"
}
}
3 changes: 2 additions & 1 deletion web/src/FAQ/FAQ.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Logo from "../components/logo/Logo"
import NoiseBackground from "../components/background/NoiseBackground"
import BlueButton from "../components/buttons/BlueButton"
import FaqItem from './FaqItem'
import fetchWithAuth from '../utils/fetcher'
import './FAQ.css'

/* Answers are JSX rather than strings because several need inline code, lists,
Expand Down Expand Up @@ -485,7 +486,7 @@ function Faq() {
let alive = true;
(async () => {
try {
const response = await fetch('/api/auth/valid_login');
const response = await fetchWithAuth('/api/auth/valid_login');
if (response.ok) {
const json = await response.json();
if (alive) setLoggedIn(json['message'] === 'true');
Expand Down
5 changes: 4 additions & 1 deletion web/src/Index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { StrictMode, useEffect, useState } from 'react'
import auth from './utils/auth'
import fetchWithAuth from './utils/fetcher'
import { createRoot } from 'react-dom/client'
import Navbar from "./components/navbar/Navbar"
import Logo from "./components/logo/Logo"
Expand All @@ -13,7 +15,8 @@ function Home() {
let alive = true;
(async () => {
try {
const response = await fetch('/api/auth/valid_login');
await auth.init()
const response = await fetchWithAuth('/api/auth/valid_login')
if (response.ok) {
const json = await response.json();
if (alive) setLoggedIn(json['message'] === 'true');
Expand Down
3 changes: 2 additions & 1 deletion web/src/about/About.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { StrictMode, useEffect, useState } from 'react'
import fetchWithAuth from '../utils/fetcher'
import { createRoot } from 'react-dom/client'
import Navbar from "../components/navbar/Navbar"
import Logo from "../components/logo/Logo"
Expand Down Expand Up @@ -47,7 +48,7 @@ function About() {
let alive = true;
(async () => {
try {
const response = await fetch('/api/auth/valid_login');
const response = await fetchWithAuth('/api/auth/valid_login')
if (response.ok) {
const json = await response.json();
if (alive) setLoggedIn(json['message'] === 'true');
Expand Down
11 changes: 6 additions & 5 deletions web/src/account/AccountSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import fetchWithAuth from '../utils/fetcher';
import BlueButton from "../components/buttons/BlueButton";
import TextField from "../components/textfield/Textfield";

Expand Down Expand Up @@ -30,7 +31,7 @@ function AccountSettings() {
const getIsLoggedIn = async () => {
if(!loaded) {
try {
var response = await fetch('/api/auth/valid_login');
var response = await fetchWithAuth('/api/auth/valid_login');
if (response.ok) {
var json = await response.json();
var loggedIn = json['message'] == 'true';
Expand All @@ -48,7 +49,7 @@ function AccountSettings() {
const getUserName =async () => {
if (!loaded) {
try {
var response = await fetch('/api/auth/user_name');
var response = await fetchWithAuth('/api/auth/user_name');
if (response.ok) {
var json = await response.json();
var firstName = json['FirstName'];
Expand Down Expand Up @@ -79,7 +80,7 @@ function AccountSettings() {
setSubmitState(ButtonState.Waiting);
setPasswordButtonDisabled(true);
try {
var response = await fetch('/api/auth/password', {method: "POST"});
var response = await fetchWithAuth('/api/auth/password', {method: "POST"});
if (response.ok) {
setPasswordMessage("Password reset link sent!")
} else {
Expand All @@ -95,7 +96,7 @@ function AccountSettings() {

const onSignOut = async () => {
try {
await fetch('/api/auth/logout', { method: "POST" });
await fetchWithAuth('/api/auth/logout', { method: "POST" });
} finally {
window.location.href = "/login";
}
Expand All @@ -106,7 +107,7 @@ function AccountSettings() {
setSubmitState(ButtonState.Error);
} else {
try {
var response = await fetch('/api/auth/user_info', {
var response = await fetchWithAuth('/api/auth/user_info', {
method:"PUT",
headers: {
'Content-Type': 'application/json'
Expand Down
26 changes: 24 additions & 2 deletions web/src/account/account.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import auth from "../utils/auth";
import Navbar from "../components/navbar/Navbar";
import './account.css'
import AccountSettings from "./AccountSettings";

function AccountApp() {
const [ready, setReady] = useState(false);

useEffect(() => {
let alive = true;
(async () => {
await auth.init();
if (alive) setReady(true);
})();
return () => {
alive = false;
};
}, []);

if (!ready) return null;

return <div>
<Navbar />
<AccountSettings />
</div>;
}

createRoot(document.getElementById('root')!).render(<div>
<Navbar />
<AccountSettings />
<AccountApp />
</div>)
5 changes: 3 additions & 2 deletions web/src/assignment/AssignmentBody.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import fetchWithAuth from '../utils/fetcher';
import AssignmentPanel from "../components/assignment/AssignmentPanel";
import Navbar from "../components/navbar/Navbar";

Expand All @@ -12,7 +13,7 @@ function AssignmentBody() {
useEffect(() => {
var isError = false;
const getAssignment = async () => {
var response = await fetch(`/api/classroom/assignment/${assignmentId}`);
var response = await fetchWithAuth(`/api/classroom/assignment/${assignmentId}`);
if (response.ok) {
var json = await response.json();
setAssignmentInfo(json);
Expand All @@ -22,7 +23,7 @@ function AssignmentBody() {
}
const verifyLogin = async () => {
try {
var response = await fetch('/api/auth/valid_login');
var response = await fetchWithAuth('/api/auth/valid_login');
if (response.ok) {
var json = await response.json();
if (json['message'] != 'true') {
Expand Down
24 changes: 23 additions & 1 deletion web/src/assignment/assignment.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import auth from "../utils/auth";
import AssignmentBody from "./AssignmentBody";

function AssignmentApp() {
const [ready, setReady] = useState(false);

useEffect(() => {
let alive = true;
(async () => {
await auth.init();
if (alive) setReady(true);
})();

return () => {
alive = false;
};
}, []);

return ready ? <AssignmentBody /> : null;
}

createRoot(document.getElementById('root')!).render(
<AssignmentBody />
<StrictMode>
<AssignmentApp />
</StrictMode>
)
Loading
Loading