From d5a861c723706f61a17fc74ee43d1d4b3801bf30 Mon Sep 17 00:00:00 2001 From: Ran <16112591+chen-ran@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:56:27 +0800 Subject: [PATCH 1/8] chore(ci): optimize docker builds with native arm64 runners --- .github/workflows/docker.yml | 168 +++++++++++++++++++++++++---------- docker/Dockerfile.agent | 3 +- docker/Dockerfile.mcp | 9 +- docker/Dockerfile.server | 51 ++++------- docker/Dockerfile.web | 2 +- 5 files changed, 150 insertions(+), 83 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7baef3af..53f765b9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -22,16 +22,22 @@ concurrency: group: docker-${{ github.ref }} cancel-in-progress: true +env: + PUSH: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') }} + REGISTRY: ghcr.io + permissions: contents: read packages: write id-token: write jobs: - docker: - runs-on: ubuntu-latest + build: strategy: + fail-fast: false matrix: + image: [server, agent, web, mcp] + platform: [linux/amd64, linux/arm64] include: - image: server dockerfile: docker/Dockerfile.server @@ -41,10 +47,114 @@ jobs: dockerfile: docker/Dockerfile.web - image: mcp dockerfile: docker/Dockerfile.mcp + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Set up Go + if: matrix.image == 'server' || matrix.image == 'mcp' + uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache: true + + - name: Pre-warm Go mod cache + if: matrix.image == 'server' || matrix.image == 'mcp' + run: | + mkdir -p .go-cache + GOMODCACHE=$(pwd)/.go-cache go mod download + + - name: Login to Docker Hub + if: env.PUSH == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + if: env.PUSH == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platform }} + outputs: ${{ env.PUSH == 'true' && format('type=image,"name={0}/{1}/{2}",push-by-digest=true,name-canonical=true,push=true,compression=zstd', env.REGISTRY, github.repository_owner, matrix.image) || '' }} + build-contexts: ${{ (matrix.image == 'server' || matrix.image == 'mcp') && format('gomodcache={0}/.go-cache', github.workspace) || '' }} + build-args: | + VERSION=${{ github.ref_name }} + COMMIT_HASH=${{ github.sha }} + VITE_API_URL=/api + VITE_AGENT_URL=/agent + cache-from: | + type=gha,scope=${{ matrix.image }}-${{ matrix.platform }} + type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:buildcache-${{ matrix.platform == 'linux/amd64' && 'amd64' || 'arm64' }} + cache-to: | + type=gha,scope=${{ matrix.image }}-${{ matrix.platform }},mode=max + ${{ env.PUSH == 'true' && format('type=registry,ref={0}/{1}/{2}:buildcache-{3},mode=max,compression=zstd', env.REGISTRY, github.repository_owner, matrix.image, matrix.platform == 'linux/amd64' && 'amd64' || 'arm64') || '' }} + + - name: Export digest + if: env.PUSH == 'true' + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + if: env.PUSH == 'true' + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.image }}-${{ strategy.job-index }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') + needs: build + strategy: + matrix: + image: [server, agent, web, mcp] + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-${{ matrix.image }}-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Docker meta id: meta uses: docker/metadata-action@v5 @@ -59,50 +169,12 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha - labels: | - org.opencontainers.image.title=memoh-${{ matrix.image }} - org.opencontainers.image.description=Memoh ${{ matrix.image }} - Multi-member AI agent platform - org.opencontainers.image.vendor=memohai - - name: Set up QEMU - if: startsWith(github.ref, 'refs/tags/') - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to GitHub Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v6 - with: - context: . - file: ${{ matrix.dockerfile }} - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: ${{ startsWith(github.ref, 'refs/tags/') && 'linux/amd64,linux/arm64' || 'linux/amd64' }} - build-args: | - VERSION=${{ steps.meta.outputs.version }} - COMMIT_HASH=${{ github.sha }} - BUILD_TIME=${{ fromJson(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} - VITE_API_URL=/api - VITE_AGENT_URL=/agent - provenance: ${{ startsWith(github.ref, 'refs/tags/') }} - sbom: ${{ startsWith(github.ref, 'refs/tags/') }} - cache-from: type=gha,scope=${{ matrix.image }} - cache-to: type=gha,scope=${{ matrix.image }},mode=max + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf 'ghcr.io/${{ github.repository_owner }}/${{ matrix.image }}@sha256:%s ' *) + env: + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index 891a64b7..48407f4b 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -1,4 +1,5 @@ -FROM oven/bun:1 AS builder +# syntax=docker/dockerfile:1 +FROM --platform=$BUILDPLATFORM oven/bun:1 AS builder WORKDIR /build diff --git a/docker/Dockerfile.mcp b/docker/Dockerfile.mcp index 065decf1..2de72f1f 100644 --- a/docker/Dockerfile.mcp +++ b/docker/Dockerfile.mcp @@ -1,9 +1,16 @@ # syntax=docker/dockerfile:1 -FROM golang:1.25-alpine AS build +FROM scratch AS gomodcache + +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build WORKDIR /src COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=bind,from=gomodcache,target=/tmp/gomodcache \ + set -eux; \ + if [ -d /tmp/gomodcache/cache/download ]; then \ + cp -a /tmp/gomodcache/. /go/pkg/mod/; \ + fi; \ go mod download COPY . . diff --git a/docker/Dockerfile.server b/docker/Dockerfile.server index 8d16ba06..06df6301 100644 --- a/docker/Dockerfile.server +++ b/docker/Dockerfile.server @@ -1,37 +1,33 @@ # syntax=docker/dockerfile:1 -# ---- Stage 1: Build server binary ---- -FROM golang:1.25-alpine AS server-builder +# ---- Stage 0: Cache Context Fallback ---- +FROM scratch AS gomodcache +# ---- Stage 1: Build base with dependencies ---- +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS build-base WORKDIR /build RUN apk add --no-cache git make - COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=bind,from=gomodcache,target=/tmp/gomodcache \ + set -eux; \ + if [ -d /tmp/gomodcache/cache/download ]; then \ + cp -a /tmp/gomodcache/. /go/pkg/mod/; \ + fi; \ go mod download - COPY . . +# ---- Stage 2: Build server binary ---- +FROM build-base AS server-builder ARG VERSION=dev ARG COMMIT_HASH=unknown ARG BUILD_TIME=unknown ARG TARGETOS ARG TARGETARCH - RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ set -eux; \ - build_os="${TARGETOS:-linux}"; \ - build_arch="${TARGETARCH:-$(uname -m)}"; \ - case "$build_arch" in \ - x86_64) build_arch="amd64" ;; \ - aarch64) build_arch="arm64" ;; \ - esac; \ - case "$build_arch" in \ - amd64|arm64) ;; \ - *) echo "unsupported TARGETARCH: $build_arch (only amd64/arm64)"; exit 1 ;; \ - esac; \ - CGO_ENABLED=0 GOOS="$build_os" GOARCH="$build_arch" \ + CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} \ go build -trimpath \ -ldflags "-s -w \ -X github.com/memohai/memoh/internal/version.Version=${VERSION} \ @@ -39,23 +35,13 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -X github.com/memohai/memoh/internal/version.BuildTime=${BUILD_TIME}" \ -o memoh-server ./cmd/agent/main.go -# ---- Stage 2: Build MCP binary ---- -FROM golang:1.25-alpine AS mcp-builder - -WORKDIR /src -RUN apk add --no-cache ca-certificates git - -COPY go.mod go.sum ./ -RUN --mount=type=cache,target=/go/pkg/mod \ - go mod download - -COPY . . - -ARG TARGETARCH=amd64 +# ---- Stage 3: Build MCP binary ---- +FROM build-base AS mcp-builder +ARG TARGETARCH ARG COMMIT_HASH=unknown RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ + CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} \ go build -trimpath \ -ldflags "-s -w -X github.com/memohai/memoh/internal/version.CommitHash=${COMMIT_HASH}" \ -o /out/mcp ./cmd/mcp @@ -88,14 +74,15 @@ FROM alpine:latest AS oci-exporter COPY --from=mcp-rootfs /tmp/rootfs.tar /tmp/layer.tar ARG MCP_IMAGE_TAG=docker.io/library/memoh-mcp:latest +ARG TARGETARCH RUN set -e \ && LAYER_SHA=$(sha256sum /tmp/layer.tar | awk '{print $1}') \ && LAYER_SIZE=$(wc -c < /tmp/layer.tar) \ && mkdir -p "/tmp/image/${LAYER_SHA}" /out \ && mv /tmp/layer.tar "/tmp/image/${LAYER_SHA}/layer.tar" \ - && printf '{"architecture":"amd64","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp image"}]}' \ - "${LAYER_SHA}" > /tmp/config.json \ + && printf '{"architecture":"%s","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp image"}]}' \ + "${TARGETARCH:-amd64}" "${LAYER_SHA}" > /tmp/config.json \ && CONFIG_SHA=$(sha256sum /tmp/config.json | awk '{print $1}') \ && mv /tmp/config.json "/tmp/image/${CONFIG_SHA}.json" \ && printf '[{"Config":"%s.json","RepoTags":["%s"],"Layers":["%s/layer.tar"]}]' \ diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web index cec58def..758127f2 100644 --- a/docker/Dockerfile.web +++ b/docker/Dockerfile.web @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1 -FROM node:25-alpine AS builder +FROM --platform=$BUILDPLATFORM node:25-alpine AS builder WORKDIR /build From 4d668b177ab3f1eb9463d08f092a3112fa837298 Mon Sep 17 00:00:00 2001 From: Ran <16112591+chen-ran@users.noreply.github.com> Date: Tue, 24 Feb 2026 06:03:26 +0800 Subject: [PATCH 2/8] chore: update docker badge --- README.md | 1 + README_CN.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index a699b940..7565030b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Forks Last Commit Issues + Docker
[Telegram Group] diff --git a/README_CN.md b/README_CN.md index 6fea3dbb..fa006709 100644 --- a/README_CN.md +++ b/README_CN.md @@ -15,6 +15,7 @@ Forks Last Commit Issues + Docker
[Telegram 群组] From 0c6a9053170918845500f4fb048919a3a56858d4 Mon Sep 17 00:00:00 2001 From: Ran <16112591+chen-ran@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:21:18 +0800 Subject: [PATCH 3/8] feat(auth): implement JWT token refresh mechanism --- agent/src/index.ts | 50 ++++++++++++++++++-- agent/src/modules/chat.ts | 33 ++++++++------ internal/auth/jwt.go | 44 ++++++++++++++++++ internal/auth/jwt_test.go | 96 +++++++++++++++++++++++++++++++++++++++ internal/handlers/auth.go | 33 ++++++++++++++ 5 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 internal/auth/jwt_test.go diff --git a/agent/src/index.ts b/agent/src/index.ts index 4ae5c75d..dd69cdb4 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -3,7 +3,7 @@ import { chatModule } from './modules/chat' import { corsMiddleware } from './middlewares/cors' import { errorMiddleware } from './middlewares/error' import { loadConfig, getBaseUrl as getBaseUrlByConfig } from '@memoh/config' -import { AuthFetcher } from '@memoh/agent' +import { AgentAuthContext, AuthFetcher } from '@memoh/agent' const config = loadConfig('../config.toml') @@ -11,12 +11,54 @@ export const getBaseUrl = () => { return getBaseUrlByConfig(config) } -export const createAuthFetcher = (bearer: string | undefined): AuthFetcher => { +function parseJwtExp(token: string): number | null { + try { + const base64Url = token.split('.')[1] + if (!base64Url) return null + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = Buffer.from(base64, 'base64').toString('utf8') + const payload = JSON.parse(jsonPayload) + return payload.exp ? payload.exp * 1000 : null + } catch (e) { + return null + } +} + +let refreshPromise: Promise | null = null + +export const createAuthFetcher = (auth: AgentAuthContext): AuthFetcher => { return async (url: string, options?: RequestInit) => { + if (auth.bearer) { + const exp = parseJwtExp(auth.bearer) + if (exp !== null && exp - Date.now() < 120000) { // Refresh if expiring in < 2 mins + if (!refreshPromise) { + refreshPromise = (async () => { + const refreshUrl = new URL('/auth/refresh', `${getBaseUrl().replace(/\/$/, '')}/`).toString() + const res = await fetch(refreshUrl, { + method: 'POST', + headers: { 'Authorization': `Bearer ${auth.bearer}` } + }) + if (res.ok) { + const data = await res.json() + return data.access_token + } + throw new Error('Failed to refresh token') + })().finally(() => { + refreshPromise = null + }) + } + try { + auth.bearer = await refreshPromise + } catch (e) { + console.error('Token refresh failed', e) + } + } + } + const requestOptions = options ?? {} const headers = new Headers(requestOptions.headers || {}) - if (bearer && !headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${bearer}`) + if (auth.bearer && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${auth.bearer}`) } const baseURL = getBaseUrl() diff --git a/agent/src/modules/chat.ts b/agent/src/modules/chat.ts index 4758ba91..a70a8c4f 100644 --- a/agent/src/modules/chat.ts +++ b/agent/src/modules/chat.ts @@ -25,7 +25,11 @@ export const chatModule = new Elysia({ prefix: '/chat' }) .use(bearerMiddleware) .post('/', async ({ body, bearer }) => { console.log('chat', body) - const authFetcher = createAuthFetcher(bearer) + const auth = { + bearer: bearer!, + baseUrl: getBaseUrl(), + } + const authFetcher = createAuthFetcher(auth) const { ask } = createAgent({ model: body.model as ModelConfig, activeContextTime: body.activeContextTime, @@ -33,10 +37,7 @@ export const chatModule = new Elysia({ prefix: '/chat' }) currentChannel: body.currentChannel, allowedActions: body.allowedActions, identity: body.identity, - auth: { - bearer: bearer!, - baseUrl: getBaseUrl(), - }, + auth, skills: body.usableSkills, mcpConnections: body.mcpConnections, inbox: body.inbox, @@ -55,7 +56,11 @@ export const chatModule = new Elysia({ prefix: '/chat' }) .post('/stream', async function* ({ body, bearer }) { console.log('stream', body) try { - const authFetcher = createAuthFetcher(bearer) + const auth = { + bearer: bearer!, + baseUrl: getBaseUrl(), + } + const authFetcher = createAuthFetcher(auth) const { stream } = createAgent({ model: body.model as ModelConfig, activeContextTime: body.activeContextTime, @@ -63,10 +68,7 @@ export const chatModule = new Elysia({ prefix: '/chat' }) currentChannel: body.currentChannel, allowedActions: body.allowedActions, identity: body.identity, - auth: { - bearer: bearer!, - baseUrl: getBaseUrl(), - }, + auth, skills: body.usableSkills, mcpConnections: body.mcpConnections, inbox: body.inbox, @@ -96,17 +98,18 @@ export const chatModule = new Elysia({ prefix: '/chat' }) }) .post('/trigger-schedule', async ({ body, bearer }) => { console.log('trigger-schedule', body) - const authFetcher = createAuthFetcher(bearer) + const auth = { + bearer: bearer!, + baseUrl: getBaseUrl(), + } + const authFetcher = createAuthFetcher(auth) const { triggerSchedule } = createAgent({ model: body.model as ModelConfig, activeContextTime: body.activeContextTime, channels: body.channels, currentChannel: body.currentChannel, identity: body.identity, - auth: { - bearer: bearer!, - baseUrl: getBaseUrl(), - }, + auth, skills: body.usableSkills, mcpConnections: body.mcpConnections, inbox: body.inbox, diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go index 86854928..340cab23 100644 --- a/internal/auth/jwt.go +++ b/internal/auth/jwt.go @@ -159,6 +159,50 @@ func ChatTokenFromContext(c echo.Context) (ChatToken, error) { return info, nil } +// RefreshTokenFromContext extracts the current token from context and issues a new one +// with the same claims but a renewed expiration time. +func RefreshTokenFromContext(c echo.Context, secret string, defaultExpiresIn time.Duration) (string, time.Time, error) { + token, ok := c.Get("user").(*jwt.Token) + if !ok || token == nil || !token.Valid { + return "", time.Time{}, echo.NewHTTPError(http.StatusUnauthorized, "invalid token") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", time.Time{}, echo.NewHTTPError(http.StatusUnauthorized, "invalid token claims") + } + + // Calculate original duration if possible + expiresIn := defaultExpiresIn + if expRaw, ok := claims["exp"].(float64); ok { + if iatRaw, ok := claims["iat"].(float64); ok { + duration := time.Duration(expRaw-iatRaw) * time.Second + if duration > 0 { + expiresIn = duration + } + } + } + + now := time.Now().UTC() + expiresAt := now.Add(expiresIn) + + // Create new claims, copying over existing ones but updating time bounds + newClaims := jwt.MapClaims{} + for k, v := range claims { + newClaims[k] = v + } + newClaims["iat"] = now.Unix() + newClaims["exp"] = expiresAt.Unix() + + newToken := jwt.NewWithClaims(jwt.SigningMethodHS256, newClaims) + signed, err := newToken.SignedString([]byte(secret)) + if err != nil { + return "", time.Time{}, err + } + + return signed, expiresAt, nil +} + func claimString(claims jwt.MapClaims, key string) string { raw, ok := claims[key] if !ok || raw == nil { diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go new file mode 100644 index 00000000..2908c22e --- /dev/null +++ b/internal/auth/jwt_test.go @@ -0,0 +1,96 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestRefreshTokenFromContext(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + secret := "test-secret" + userID := "user-123" + + // Create an initial token with a 5-minute lifespan + initialDuration := 5 * time.Minute + initialTokenStr, _, err := GenerateToken(userID, secret, initialDuration) + assert.NoError(t, err) + + // Parse the token to place it into the echo context + token, err := jwt.Parse(initialTokenStr, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + assert.NoError(t, err) + c.Set("user", token) + + // Simulate some time passing to ensure the new token has a different 'iat' and 'exp' + time.Sleep(1 * time.Second) + + // Run the refresh function + defaultDuration := 1 * time.Hour + newTokenStr, newExpiresAt, err := RefreshTokenFromContext(c, secret, defaultDuration) + assert.NoError(t, err) + assert.NotEmpty(t, newTokenStr) + + // Parse the original token claims for comparison + originalClaims, ok := token.Claims.(jwt.MapClaims) + assert.True(t, ok) + origIat := int64(originalClaims["iat"].(float64)) + origExp := int64(originalClaims["exp"].(float64)) + + // Parse the new token + newToken, err := jwt.Parse(newTokenStr, func(token *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + assert.NoError(t, err) + assert.True(t, newToken.Valid) + + newClaims, ok := newToken.Claims.(jwt.MapClaims) + assert.True(t, ok) + + // Ensure standard payload claims are retained + assert.Equal(t, userID, newClaims[claimSubject]) + assert.Equal(t, userID, newClaims[claimUserID]) + + // Validate the new time bounds + newIat := int64(newClaims["iat"].(float64)) + newExp := int64(newClaims["exp"].(float64)) + + // 1. Ensure time has advanced + assert.Greater(t, newIat, origIat) + + // 2. Ensure it calculated the original duration and used it (5 mins), NOT the default 1 hour + assert.Equal(t, newExp-newIat, origExp-origIat) + assert.Equal(t, int64(5*60), newExp-newIat) + + // 3. Ensure the return value matches the claim + assert.Equal(t, newExpiresAt.Unix(), newExp) +} + +func TestRefreshTokenFromContext_MissingUser(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodPost, "/", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + secret := "test-secret" + defaultDuration := 1 * time.Hour + + // Context without the "user" key + _, _, err := RefreshTokenFromContext(c, secret, defaultDuration) + assert.Error(t, err) + + httpErr, ok := err.(*echo.HTTPError) + assert.True(t, ok) + assert.Equal(t, http.StatusUnauthorized, httpErr.Code) + assert.Equal(t, "invalid token", httpErr.Message) +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index dfb4503d..d3deb6f8 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -46,6 +46,7 @@ func NewAuthHandler(log *slog.Logger, accountService *accounts.Service, jwtSecre func (h *AuthHandler) Register(e *echo.Echo) { e.POST("/auth/login", h.Login) + e.POST("/auth/refresh", h.Refresh) } // Login godoc @@ -103,3 +104,35 @@ func (h *AuthHandler) Login(c echo.Context) error { DisplayName: account.DisplayName, }) } + +type RefreshResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresAt string `json:"expires_at"` +} + +// Refresh godoc +// @Summary Refresh Token +// @Description Issue a new JWT using the existing claims with updated expiration +// @Tags auth +// @Security BearerAuth +// @Success 200 {object} RefreshResponse +// @Failure 401 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /auth/refresh [post] +func (h *AuthHandler) Refresh(c echo.Context) error { + if strings.TrimSpace(h.jwtSecret) == "" { + return echo.NewHTTPError(http.StatusInternalServerError, "jwt secret not configured") + } + + token, expiresAt, err := auth.RefreshTokenFromContext(c, h.jwtSecret, h.expiresIn) + if err != nil { + return echo.NewHTTPError(http.StatusUnauthorized, err.Error()) + } + + return c.JSON(http.StatusOK, RefreshResponse{ + AccessToken: token, + TokenType: "Bearer", + ExpiresAt: expiresAt.Format(time.RFC3339), + }) +} From 8bbdb2b0225d2f704fc64a966f42aef736c88053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:49:01 +0800 Subject: [PATCH 4/8] Update agent/src/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- agent/src/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/agent/src/index.ts b/agent/src/index.ts index dd69cdb4..c34c9599 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -24,9 +24,8 @@ function parseJwtExp(token: string): number | null { } } -let refreshPromise: Promise | null = null - export const createAuthFetcher = (auth: AgentAuthContext): AuthFetcher => { + let refreshPromise: Promise | null = null return async (url: string, options?: RequestInit) => { if (auth.bearer) { const exp = parseJwtExp(auth.bearer) From 54b337e39152a08f73dc8ea3b63a16eb2ed260f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:49:28 +0800 Subject: [PATCH 5/8] Update agent/src/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- agent/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/src/index.ts b/agent/src/index.ts index c34c9599..515d6876 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -50,6 +50,7 @@ export const createAuthFetcher = (auth: AgentAuthContext): AuthFetcher => { auth.bearer = await refreshPromise } catch (e) { console.error('Token refresh failed', e) + throw e } } } From 579bf45fc259218f49ef724258850167c9521389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:59:08 +0800 Subject: [PATCH 6/8] Update internal/auth/jwt_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- internal/auth/jwt_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go index 2908c22e..5ccbf8ba 100644 --- a/internal/auth/jwt_test.go +++ b/internal/auth/jwt_test.go @@ -68,9 +68,10 @@ func TestRefreshTokenFromContext(t *testing.T) { // 1. Ensure time has advanced assert.Greater(t, newIat, origIat) - // 2. Ensure it calculated the original duration and used it (5 mins), NOT the default 1 hour - assert.Equal(t, newExp-newIat, origExp-origIat) - assert.Equal(t, int64(5*60), newExp-newIat) + // 2. Ensure the refreshed token has a positive lifetime and does not exceed the configured default duration + lifetimeSeconds := newExp - newIat + assert.Greater(t, lifetimeSeconds, int64(0)) + assert.LessOrEqual(t, lifetimeSeconds, int64(defaultDuration.Seconds())) // 3. Ensure the return value matches the claim assert.Equal(t, newExpiresAt.Unix(), newExp) From 3e3379a869effb10959a89ec6fa2d996545cdb56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=A8=E8=8B=92?= <16112591+chen-ran@users.noreply.github.com> Date: Tue, 24 Feb 2026 05:04:36 +0800 Subject: [PATCH 7/8] Update agent/src/index.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- agent/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/agent/src/index.ts b/agent/src/index.ts index 515d6876..8a82c950 100644 --- a/agent/src/index.ts +++ b/agent/src/index.ts @@ -20,6 +20,7 @@ function parseJwtExp(token: string): number | null { const payload = JSON.parse(jsonPayload) return payload.exp ? payload.exp * 1000 : null } catch (e) { + console.error('Failed to parse JWT expiration from token', e) return null } } From eeef8a46c2c3e388c11686f86c9cc8d263e5aa5b Mon Sep 17 00:00:00 2001 From: Ran <16112591+chen-ran@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:58:04 +0800 Subject: [PATCH 8/8] chore(ci): update github action --- .github/workflows/docker.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 53f765b9..d22b5e1f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,7 +2,7 @@ name: Docker on: push: - branches: [main] + branches: [main, "v*.*"] tags: ["v*"] paths-ignore: - "docs/**" @@ -11,7 +11,7 @@ on: release: types: [published] pull_request: - branches: [main] + branches: [main, "v*.*"] paths-ignore: - "docs/**" - "**.md" @@ -23,7 +23,7 @@ concurrency: cancel-in-progress: true env: - PUSH: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') }} + PUSH: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') }} REGISTRY: ghcr.io permissions: @@ -126,7 +126,7 @@ jobs: merge: runs-on: ubuntu-latest - if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/v') || startsWith(github.ref, 'refs/tags/') || github.event_name == 'release') needs: build strategy: matrix: