Feat v0.6.9 cookie fix roadmap (#44)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #44.
This commit is contained in:
56
CHANGELOG.md
56
CHANGELOG.md
@@ -2,6 +2,62 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.6.9 — Session Lifetime Config
|
||||||
|
|
||||||
|
Admin-configurable session durations, "keep me logged in" opt-in, and
|
||||||
|
optional idle timeout. Completes the auth hardening started in v0.6.8.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Admin session settings**: `session.access_token_ttl` (default 15m,
|
||||||
|
clamp 5m–60m) and `session.refresh_token_ttl` (default 7d, clamp
|
||||||
|
1h–90d) stored in `global_settings`. New `LoadSessionConfig()` helper
|
||||||
|
reads and clamps values with `parseDurationString()` supporting `m`,
|
||||||
|
`h`, `d` suffixes.
|
||||||
|
- **"Keep me logged in" checkbox**: Login form opt-in. Checked = full
|
||||||
|
`refresh_token_ttl`. Unchecked = capped at 24h. Cookie `max-age`
|
||||||
|
tracks whichever lifetime was chosen. `keep_login` flag stored on
|
||||||
|
refresh token row.
|
||||||
|
- **Config-driven token generation**: `generateTokens()` reads TTLs from
|
||||||
|
`global_settings` instead of hardcoded `15*time.Minute` /
|
||||||
|
`7*24*time.Hour`. Response includes `expires_in` and
|
||||||
|
`refresh_expires_in` (seconds) so the client schedules refresh
|
||||||
|
correctly.
|
||||||
|
- **Idle timeout (optional)**: Admin-toggleable (default off). Server
|
||||||
|
checks `last_activity_at` on refresh-token row; rejects if gap exceeds
|
||||||
|
`session.idle_timeout`. Client SDK pings `POST /api/v1/auth/activity`
|
||||||
|
on click/keydown (debounced, max 1/min).
|
||||||
|
- **Admin Settings > Session section**: Dropdowns for access TTL, refresh
|
||||||
|
TTL, and idle timeout with toggle.
|
||||||
|
- **7 new tests**: Duration parsing, clamping (low/high), defaults,
|
||||||
|
invalid values, empty idle timeout.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `CreateRefreshToken` store method now accepts `keepLogin bool`
|
||||||
|
parameter; both Postgres and SQLite implementations updated.
|
||||||
|
- `GetRefreshTokenInfo` returns `RefreshTokenInfo` struct with
|
||||||
|
`UserID`, `KeepLogin`, `LastActivityAt` for idle-timeout decisions.
|
||||||
|
- SDK `auth.login()` accepts optional third `keepLogin` parameter;
|
||||||
|
cookie max-age derived from server `refresh_expires_in` response.
|
||||||
|
- SDK boots activity tracking after successful auth boot.
|
||||||
|
|
||||||
|
## v0.6.8 — Cookie Fix + UI Hardening Roadmap
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Session cookie max-age bug**: `arm_token` cookie was set to 15 min
|
||||||
|
(matching access token) while refresh token lasted 7 days. Cookie now
|
||||||
|
matches refresh token lifetime so Go SSR middleware can serve page
|
||||||
|
shells while JS refreshes the access token client-side.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **ROADMAP-UI.md**: Detailed UI hardening roadmap (v0.6.9–v0.6.15)
|
||||||
|
covering session config, viewport foundation, CSS deduplication,
|
||||||
|
extension CSS isolation, responsive layout, visual polish, and
|
||||||
|
automated usability survey gate.
|
||||||
|
|
||||||
## v0.6.7 — Native mTLS
|
## v0.6.7 — Native mTLS
|
||||||
|
|
||||||
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman
|
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman
|
||||||
|
|||||||
161
ROADMAP-UI.md
Normal file
161
ROADMAP-UI.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Armature — v0.6.x UI Hardening Roadmap
|
||||||
|
|
||||||
|
> **Goal**: Fix all viewport scaling, banner integration, and styling inconsistencies
|
||||||
|
> so Claude Code can run a meaningful automated usability survey against a
|
||||||
|
> stable, uniform UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Inventory
|
||||||
|
|
||||||
|
### P1 — Viewport & Scaling (blocks everything)
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 1 | **Dual layout systems** — `base.html` template has its own banner→surface→footer column; `app-shell.js` Preact shell has a separate `sw-shell` → `sw-shell__body` → `sw-shell__surface` column. They never coordinate. | `server/pages/templates/base.html` ↔ `src/js/sw/shell/app-shell.js` | Every surface inherits an ambiguous viewport ancestor. |
|
||||||
|
| 2 | **Transform-based scaling is broken** — `transform: scale()` on `#surfaceInner` doesn't reflow layout. Breaks `getBoundingClientRect` (menu.js already carries a scale-correction hack at line 20), scroll containment, pointer events, and the inverse-width/height hack (`100/s%`) doesn't account for banners consuming viewport space. | `base.html:56-67`, `appearance.js:41-53`, `menu.js:20-33` | Menus, tooltips, dropdowns all mis-positioned at any non-100% scale. Click targets wrong. |
|
||||||
|
| 3 | **Banner height desync** — `base.html` hardcodes `--banner-h: 28px` and renders banners in-flow. `app-shell.js` ShellBanner measures `offsetHeight` into `--banner-top-height` / `--banner-bottom-height` and uses `position: fixed` + body padding. Two independent banner systems. | `base.html:31,72-75,103-107` ↔ `app-shell.js:25-42`, `sw-shell.css:22-37,63-69` | With banners enabled, content overflows by the banner height or gets double-inset depending on which code path is active. |
|
||||||
|
| 4 | **`100vh` on mobile** — `.sw-shell`, `.chat-app`, `.chat-loading`, `.login-shell` use `100vh` which doesn't subtract mobile browser chrome (address bar, toolbar). | `sw-shell.css:9`, `chat/css/main.css:15,25`, `sw-login.css:7` | Content overflows on iOS Safari, Android Chrome. |
|
||||||
|
| 5 | **Chat uses `100vh` not `100%`** — `.chat-app { height: 100vh }` ignores parent container (which already excludes banners/footer). Notes correctly uses `height: 100%`. Every extension surface that copies chat's pattern will inherit the bug. | `packages/chat/css/main.css:15` | Chat surface overflows behind banners. |
|
||||||
|
|
||||||
|
### P2 — CSS Architecture
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 6 | **Class name collisions** — `.settings-section` defined in both `modals.css:41-43` and `surfaces.css:75-83` with different padding, margin, and border-bottom rules. `.sw-dropdown` in `primitives.css:443` (styled `<select>`) vs `sw-primitives.css:262` (custom dropdown component). `.sw-tabs` in `primitives.css:459` vs `sw-primitives.css:234`. | Kernel CSS | Styles randomly win depending on load order. Bug reports differ by surface. |
|
||||||
|
| 7 | **Old/new primitive duplication** — Two button systems (`.btn-primary`/`.btn-small` in `primitives.css` vs `.sw-btn--primary`/`.sw-btn--sm` in `sw-primitives.css`). Two toast systems (`.toast` vs `.sw-toast`). Two dropdown, two tabs implementations. | `primitives.css` ↔ `sw-primitives.css` | No single source of truth for any component. Extension authors can't know which to use. |
|
||||||
|
| 8 | **Extension CSS bleeds globally** — Extension `main.css` loaded via `<link>` at document level, no scoping. Any class name can override kernel or sibling extension styles. | `base.html:26-28`, `extension-surface.css` | Package CSS fights with kernel. Two packages with `.sidebar` both lose. |
|
||||||
|
| 9 | **No intermediate breakpoint** — Only `768px` mobile breakpoint. No tablet (768–1024px). Secondary workspace pane hardcoded `480px`. Admin nav hardcoded `200px`. | `layout.css:263`, `surfaces.css:305` | Cramped layout on iPad/small laptops. |
|
||||||
|
|
||||||
|
### P3 — Visual Consistency
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 10 | **Mixed unit systems** — `px`, `rem`, `em` used interchangeably with no rationale. Some font-sizes in `px` (primitives), others in `rem` (sw-primitives), others in `em` (user-picker). | All CSS files | Zoom/scale behaves differently per element. |
|
||||||
|
| 11 | **Google Fonts CDN dependency** — `@import url(...)` in `variables.css:5` blocks rendering if CDN is slow; breaks entirely on air-gapped deployments. | `variables.css:5` | FOUT or blank page on slow networks. No offline support. |
|
||||||
|
| 12 | **No spacing scale** — Padding/margins are ad-hoc values (10px, 12px, 14px, 16px, 20px, 24px, 28px…). No design-token spacing scale. | All CSS | Inconsistent visual rhythm across surfaces. |
|
||||||
|
| 13 | **Hardcoded fallback colors in component CSS** — `sw-shell.css`, `sw-primitives.css` have inline fallbacks like `var(--accent, #b38a4e)` — a gold color from a previous theme that doesn't match the current blue `#6c9fff`. | `sw-shell.css`, `sw-primitives.css` | Wrong colors flash briefly if variables load late. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### v0.6.9 — Session Lifetime Config ✅
|
||||||
|
|
||||||
|
Shipped. Admin-configurable TTLs, "keep me logged in" checkbox, idle timeout,
|
||||||
|
config-driven `generateTokens()`, 7 new tests. See CHANGELOG.md for details.
|
||||||
|
|
||||||
|
### v0.6.10 — Viewport Foundation
|
||||||
|
|
||||||
|
Establish a single, correct layout model. Every surface must render inside one
|
||||||
|
containment chain: `body → shell → surface`. No dual systems. No transform hacks.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Unify shell layout | Remove the inline `<style>` block from `base.html` that creates a parallel column layout. Make `base.html` render a single `<div class="sw-shell">` wrapper. Banners, announcements, footer, and surface all live inside the Preact shell's flex column OR the template column — pick one, kill the other. **Recommendation**: keep the Go template column (it works without JS) and delete `sw-shell.css`'s parallel layout. The Preact `AppShell` becomes a component that renders *inside* `#surfaceInner`, not a viewport-level container. |
|
||||||
|
| Banner single source of truth | One banner rendering path. Template banners (`base.html:72-107`) set `--banner-top-height` and `--banner-bottom-height` via a `<script>` that measures after DOMContentLoaded. Remove `ShellBanner`'s JS measurement. Surfaces use `calc(100vh - var(--banner-top-height) - var(--banner-bottom-height))` or pure flex containment — not both. |
|
||||||
|
| Replace transform-scale with CSS `zoom` | CSS `zoom` is now supported in all evergreen browsers (including Firefox 126+, June 2024). `zoom` actually reflows layout — `getBoundingClientRect` returns correct values, scroll containers work, pointer events are accurate. Remove all `transform: scale()` code from `base.html`, `appearance.js`, `menu.js` scale-correction hacks. Apply `zoom` on `#surfaceInner`. The `sw.shell.getScale()` API becomes a no-op returning 1 (or reads `getComputedStyle().zoom`). |
|
||||||
|
| Fix `100vh` → `100dvh` | Replace all `height: 100vh` with `height: 100dvh` (fallback: `height: 100vh; height: 100dvh;`). Affects: `sw-shell.css`, `sw-login.css`, `chat/css/main.css`. |
|
||||||
|
| Chat `100vh` → `100%` | `.chat-app` and `.chat-loading` use `height: 100%` like Notes. They inherit their height from the extension mount container which is already correctly sized. |
|
||||||
|
| Validation | Manual test matrix: banner on/off × scale 80%/100%/150% × mobile/desktop × light/dark. Every surface (admin, settings, team-admin, docs, welcome, notes, chat). No overflow, no gaps, menus positioned correctly. |
|
||||||
|
|
||||||
|
### v0.6.11 — CSS Deduplication
|
||||||
|
|
||||||
|
Kill the old primitive system. One class per concept.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Audit collision inventory | Script that finds all duplicate class selectors across kernel CSS files. Produce a machine-readable collision report (JSON). |
|
||||||
|
| Migrate `.settings-section` | Remove `modals.css:41-43` definition. The `surfaces.css` definition is authoritative. Modals that used the old styles get explicit overrides scoped to `.modal .settings-section`. |
|
||||||
|
| Retire `primitives.css` old components | `.btn-primary`, `.btn-small`, `.btn-danger`, `.btn-full` → migrate all usages to `.sw-btn--*`. `.toast-container` / `.toast` → migrate to `.sw-toast-container` / `.sw-toast`. Old `.popup-menu` → migrate to `.sw-menu`. Old `.sw-dropdown` (styled select in primitives.css) → rename to `.sw-native-select` or delete if unused. Old `.sw-tabs` / `.sw-tab-btn` in primitives.css → delete (sw-primitives.css version is authoritative). |
|
||||||
|
| Mark deprecated classes | Any remaining old classes get a `/* DEPRECATED v0.6.11 — use .sw-btn--* */` comment and a 1-version grace period for package authors. |
|
||||||
|
| Package CSS audit | Scan all `packages/*/css/main.css` for references to deprecated kernel classes. Fix in-tree packages. |
|
||||||
|
|
||||||
|
### v0.6.12 — Extension CSS Isolation
|
||||||
|
|
||||||
|
Prevent extension CSS from leaking into the kernel or sibling extensions.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Scoping strategy | Two options: (A) `@scope (.extension-mount[data-ext="slug"])` — CSS `@scope` is supported in Chrome 118+, Firefox 128+, Safari 17.4+. (B) Prefix enforcement — package CSS linter rejects selectors that don't start with `.ext-{slug}` or `[data-ext="{slug}"]`. **Recommendation**: (B) prefix enforcement. `@scope` support is still patchy; prefix enforcement works everywhere and is trivially lintable. |
|
||||||
|
| Linter | `scripts/lint-package-css.sh` — shell script using `grep`/`awk`. Runs in CI. Accepts `.ext-{slug}` or `[data-ext="{slug}"]` prefixed selectors only. Exempts `:root`, `@keyframes`, `@font-face`, and `@media` wrappers. |
|
||||||
|
| Kernel CSS contract | Document which kernel classes are stable public API for extensions to reference (`.sw-btn--*`, `.sw-input`, `.sw-field`, `.sw-dialog`, `.sw-toast`, `.sw-menu`, `.sw-tabs`, CSS variables). Everything else is internal. Publish in `docs/EXTENSION-CSS.md`. |
|
||||||
|
| Migrate in-tree packages | Add `data-ext` attribute to extension mount container. Prefix all in-tree package CSS. Verify no visual regressions. |
|
||||||
|
|
||||||
|
### v0.6.13 — Responsive & Spacing
|
||||||
|
|
||||||
|
Fill the gaps between mobile and desktop.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Tablet breakpoint | Add `@media (min-width: 769px) and (max-width: 1024px)` rules. Secondary workspace pane: `360px` (from `480px`). Admin nav collapses to icon-only at this breakpoint. Settings nav: `180px`. |
|
||||||
|
| Spacing scale | Define spacing tokens in `variables.css`: `--space-1: 4px` through `--space-8: 64px` (4px base, doubling). Migrate hardcoded padding/margin values to tokens. Priority: surfaces that users see on every session (sidebar, topbar, admin content areas). |
|
||||||
|
| Font-size scale | Define `--text-xs: 0.75rem` through `--text-xl: 1.25rem` (5 stops). Migrate all font-size declarations. Remove `em`-based sizes (user-picker) and bare `px` sizes. |
|
||||||
|
| Secondary pane responsive | `workspace-secondary.open` width uses `clamp(320px, 35vw, 560px)` instead of fixed `480px`. |
|
||||||
|
|
||||||
|
### v0.6.14 — Visual Polish
|
||||||
|
|
||||||
|
Systematic cleanup of stale values and rendering artifacts.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Purge stale fallback colors | Remove all `var(--foo, #hexvalue)` fallbacks that reference old gold theme colors (`#b38a4e` and variants). If a CSS variable is undefined at this point, that's a bug — don't mask it with a wrong fallback. |
|
||||||
|
| Self-host fonts | Bundle DM Sans and JetBrains Mono woff2 files in `src/fonts/`. Replace Google Fonts `@import` with local `@font-face` declarations. Zero external font dependencies. |
|
||||||
|
| Light theme audit | Walk every surface in light mode. Fix any remaining hardcoded dark-mode colors, contrast issues, or invisible borders. Document any surface-specific light-mode overrides. |
|
||||||
|
| Consistent border-radius | Surfaces currently mix `6px`, `8px`, `10px`, `var(--radius)`, `var(--radius-lg)`. Audit and reduce to 3 values: `--radius-sm: 4px`, `--radius: 8px`, `--radius-lg: 12px`. |
|
||||||
|
|
||||||
|
### v0.6.15 — Usability Survey Gate
|
||||||
|
|
||||||
|
Make the UI machine-auditable so Claude Code can run an automated survey.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| UI inventory manifest | `scripts/generate-ui-inventory.sh` — walks kernel CSS + package CSS, extracts every rendered class, groups by surface, outputs `ui-inventory.json`. Fields: `{surface, component, classes, file, line, responsive_breakpoints, spacing_tokens_used, font_size_tokens_used}`. |
|
||||||
|
| Contrast checker | Script that parses `variables.css` light/dark token pairs and checks WCAG AA contrast ratios for all `text-*` on `bg-*` combinations. Outputs pass/fail report. |
|
||||||
|
| Component coverage matrix | Markdown table: each kernel primitive (button, input, dropdown, dialog, toast, menu, tabs, avatar, spinner, tooltip, drawer, banner) × each surface that uses it. Identifies surfaces still using deprecated old-style components. |
|
||||||
|
| Touch target audit | Script that finds all `<button>`, `[role=button]`, clickable elements across templates and JS, checks for `min-width/min-height ≥ 44px` on mobile breakpoint. |
|
||||||
|
| Claude Code survey prompt | `docs/USABILITY-SURVEY.md` — structured prompt template that Claude Code can execute against the codebase. Sections: viewport correctness, banner integration, responsive behavior, styling consistency, accessibility basics (contrast, touch targets, focus indicators), component uniformity. Each section has pass/fail criteria and file paths to inspect. |
|
||||||
|
| Survey dry-run | Run the survey. Fix anything it catches. Tag `v0.6.15` only after clean survey. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing Rationale
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.6.9 Session Lifetime Config ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.10 Viewport Foundation ← Everything depends on correct containment
|
||||||
|
↓
|
||||||
|
v0.6.11 CSS Deduplication ← Can't audit styling until there's one source of truth
|
||||||
|
↓
|
||||||
|
v0.6.12 Extension CSS Isolation ← Scoping requires the kernel CSS to be stable first
|
||||||
|
↓
|
||||||
|
v0.6.13 Responsive & Spacing ← Spacing tokens need stable class names to attach to
|
||||||
|
↓
|
||||||
|
v0.6.14 Visual Polish ← Final visual pass after structure is locked
|
||||||
|
↓
|
||||||
|
v0.6.15 Usability Survey Gate ← Machine-audit the finished product
|
||||||
|
```
|
||||||
|
|
||||||
|
Each version is independently shippable and testable. No version depends on
|
||||||
|
anything after it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What "Usability Survey by Code" Means
|
||||||
|
|
||||||
|
The v0.6.15 deliverables give Claude Code (or any automated tool) three things:
|
||||||
|
|
||||||
|
1. **A structured inventory** (`ui-inventory.json`) of every component, on every
|
||||||
|
surface, with its CSS file and line number.
|
||||||
|
|
||||||
|
2. **Automated checks** (contrast, touch targets, token usage) that produce
|
||||||
|
machine-readable pass/fail reports.
|
||||||
|
|
||||||
|
3. **A survey prompt** (`USABILITY-SURVEY.md`) with explicit criteria and file
|
||||||
|
paths, so Claude Code can `cat` the relevant files, run the scripts, and
|
||||||
|
produce a scored report without human guidance.
|
||||||
|
|
||||||
|
The survey is not a substitute for real user testing — it's a structural
|
||||||
|
quality gate that catches the class of bugs (overflow, contrast, misalignment,
|
||||||
|
inconsistent styling) that have historically slipped through.
|
||||||
@@ -24,13 +24,17 @@ func (p *BuiltinProvider) SupportsRegistration() bool { return true }
|
|||||||
|
|
||||||
func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
|
func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Login string `json:"login" binding:"required"`
|
Login string `json:"login" binding:"required"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
|
KeepLogin bool `json:"keep_login"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash keep_login in context for generateTokens
|
||||||
|
c.Set("keep_login", req.KeepLogin)
|
||||||
|
|
||||||
user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login)
|
user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrInvalidCreds
|
return nil, ErrInvalidCreds
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
revoked_at TIMESTAMPTZ
|
revoked_at TIMESTAMPTZ,
|
||||||
|
keep_login BOOLEAN DEFAULT FALSE,
|
||||||
|
last_activity_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
revoked_at TEXT
|
revoked_at TEXT,
|
||||||
|
keep_login INTEGER DEFAULT 0,
|
||||||
|
last_activity_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := h.generateTokens(result.User)
|
tokens, err := h.generateTokens(result.User, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||||
return
|
return
|
||||||
@@ -94,7 +94,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
|
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
|
||||||
|
|
||||||
tokens, err := h.generateTokens(result.User)
|
// Check for keep_login flag from request body.
|
||||||
|
// The provider already consumed the body, so we read from gin context
|
||||||
|
// if the provider stashed it there; otherwise default to false.
|
||||||
|
keepLogin, _ := c.Get("keep_login")
|
||||||
|
keep, _ := keepLogin.(bool)
|
||||||
|
|
||||||
|
tokens, err := h.generateTokens(result.User, keep)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||||
return
|
return
|
||||||
@@ -113,22 +119,32 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenHash := hashToken(req.RefreshToken)
|
tokenHash := hashToken(req.RefreshToken)
|
||||||
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash)
|
info, err := h.stores.Users.GetRefreshTokenInfo(c.Request.Context(), tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idle timeout check
|
||||||
|
sc := LoadSessionConfig(c.Request.Context(), h.stores.GlobalConfig)
|
||||||
|
if sc.IdleTimeout > 0 && info.LastActivityAt != nil {
|
||||||
|
if time.Since(*info.LastActivityAt) > sc.IdleTimeout {
|
||||||
|
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired due to inactivity"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Revoke the used token (rotate)
|
// Revoke the used token (rotate)
|
||||||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||||
|
|
||||||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
user, err := h.stores.Users.GetByID(c.Request.Context(), info.UserID)
|
||||||
if err != nil || !user.IsActive {
|
if err != nil || !user.IsActive {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := h.generateTokens(user)
|
tokens, err := h.generateTokens(user, info.KeepLogin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||||
return
|
return
|
||||||
@@ -159,6 +175,19 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activity updates last_activity_at on the caller's active refresh tokens.
|
||||||
|
// Called by the client SDK on user interaction (debounced, max 1/min).
|
||||||
|
// POST /api/v1/auth/activity (requires auth middleware)
|
||||||
|
func (h *AuthHandler) Activity(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.stores.Users.UpdateRefreshTokenActivity(c.Request.Context(), userID.(string))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// ── OIDC Flow ──────────────────────────────
|
// ── OIDC Flow ──────────────────────────────
|
||||||
|
|
||||||
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
|
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
|
||||||
@@ -271,7 +300,7 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Issue internal JWT
|
// Issue internal JWT
|
||||||
tokens, err := h.generateTokens(result.User)
|
tokens, err := h.generateTokens(result.User, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||||
return
|
return
|
||||||
@@ -286,8 +315,14 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
|
|||||||
refreshToken, _ := tokens["refresh_token"].(string)
|
refreshToken, _ := tokens["refresh_token"].(string)
|
||||||
userJSON, _ := json.Marshal(tokens["user"])
|
userJSON, _ := json.Marshal(tokens["user"])
|
||||||
|
|
||||||
// Set page-auth cookie too (for SSR middleware)
|
// Set page-auth cookie too (for SSR middleware).
|
||||||
c.SetCookie("arm_token", accessToken, 900, "/", "", false, false)
|
// MaxAge matches refresh token lifetime so the cookie
|
||||||
|
// survives until JS can proactively refresh the access token.
|
||||||
|
refreshExpiresIn, _ := tokens["refresh_expires_in"].(int)
|
||||||
|
if refreshExpiresIn <= 0 {
|
||||||
|
refreshExpiresIn = 604800
|
||||||
|
}
|
||||||
|
c.SetCookie("arm_token", accessToken, refreshExpiresIn, "/", "", false, false)
|
||||||
|
|
||||||
// Base64-encode the token payload for the fragment
|
// Base64-encode the token payload for the fragment
|
||||||
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
|
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
|
||||||
@@ -298,13 +333,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, dest)
|
c.Redirect(http.StatusFound, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) {
|
||||||
// Access token (15 min)
|
sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig)
|
||||||
|
|
||||||
|
// Access token
|
||||||
accessClaims := Claims{
|
accessClaims := Claims{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(sc.AccessTokenTTL)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
},
|
},
|
||||||
@@ -315,20 +352,26 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token (7 days)
|
// Refresh token — keepLogin=false caps at 24h (session-length)
|
||||||
|
refreshTTL := sc.RefreshTokenTTL
|
||||||
|
if !keepLogin && refreshTTL > 24*time.Hour {
|
||||||
|
refreshTTL = 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
refreshRaw := uuid.New().String()
|
refreshRaw := uuid.New().String()
|
||||||
refreshHash := hashToken(refreshRaw)
|
refreshHash := hashToken(refreshRaw)
|
||||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
expiresAt := time.Now().Add(refreshTTL)
|
||||||
|
|
||||||
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil {
|
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt, keepLogin); err != nil {
|
||||||
log.Printf("warn: failed to store refresh token: %v", err)
|
log.Printf("warn: failed to store refresh token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"access_token": accessString,
|
"access_token": accessString,
|
||||||
"refresh_token": refreshRaw,
|
"refresh_token": refreshRaw,
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 900,
|
"expires_in": int(sc.AccessTokenTTL.Seconds()),
|
||||||
|
"refresh_expires_in": int(refreshTTL.Seconds()),
|
||||||
"user": gin.H{
|
"user": gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
|
|||||||
103
server/handlers/session_config.go
Normal file
103
server/handlers/session_config.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionConfig holds parsed, clamped session TTL settings.
|
||||||
|
type SessionConfig struct {
|
||||||
|
AccessTokenTTL time.Duration
|
||||||
|
RefreshTokenTTL time.Duration
|
||||||
|
IdleTimeout time.Duration // 0 = disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults & clamp bounds
|
||||||
|
const (
|
||||||
|
defaultAccessTTL = 15 * time.Minute
|
||||||
|
defaultRefreshTTL = 7 * 24 * time.Hour // 7d
|
||||||
|
defaultIdleTimeout = 0 // disabled
|
||||||
|
|
||||||
|
minAccessTTL = 5 * time.Minute
|
||||||
|
maxAccessTTL = 60 * time.Minute
|
||||||
|
minRefreshTTL = 1 * time.Hour
|
||||||
|
maxRefreshTTL = 90 * 24 * time.Hour // 90d
|
||||||
|
minIdleTTL = 5 * time.Minute
|
||||||
|
maxIdleTTL = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadSessionConfig reads session settings from the global_settings table.
|
||||||
|
// Returns defaults if the key is missing or values are unparseable.
|
||||||
|
func LoadSessionConfig(ctx context.Context, gc store.GlobalConfigStore) SessionConfig {
|
||||||
|
sc := SessionConfig{
|
||||||
|
AccessTokenTTL: defaultAccessTTL,
|
||||||
|
RefreshTokenTTL: defaultRefreshTTL,
|
||||||
|
IdleTimeout: defaultIdleTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := gc.Get(ctx, "session")
|
||||||
|
if err != nil || raw == nil {
|
||||||
|
return sc
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := raw["access_token_ttl"].(string); ok && v != "" {
|
||||||
|
if d, err := parseDurationString(v); err == nil {
|
||||||
|
sc.AccessTokenTTL = clamp(d, minAccessTTL, maxAccessTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := raw["refresh_token_ttl"].(string); ok && v != "" {
|
||||||
|
if d, err := parseDurationString(v); err == nil {
|
||||||
|
sc.RefreshTokenTTL = clamp(d, minRefreshTTL, maxRefreshTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := raw["idle_timeout"].(string); ok && v != "" {
|
||||||
|
if d, err := parseDurationString(v); err == nil {
|
||||||
|
sc.IdleTimeout = clamp(d, minIdleTTL, maxIdleTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sc
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDurationString parses strings like "15m", "2h", "7d", "30d".
|
||||||
|
// Supports suffixes: s (seconds), m (minutes), h (hours), d (days).
|
||||||
|
func parseDurationString(s string) (time.Duration, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, fmt.Errorf("empty duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Go's standard parser first (handles "15m", "2h30m", etc.)
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "Xd" suffix (days)
|
||||||
|
if strings.HasSuffix(s, "d") {
|
||||||
|
numStr := strings.TrimSuffix(s, "d")
|
||||||
|
n, err := strconv.ParseFloat(numStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||||
|
}
|
||||||
|
return time.Duration(n * float64(24*time.Hour)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(d, min, max time.Duration) time.Duration {
|
||||||
|
if d < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if d > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
188
server/handlers/session_config_test.go
Normal file
188
server/handlers/session_config_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"armature/models"
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockGlobalConfig implements store.GlobalConfigStore for tests.
|
||||||
|
type mockGlobalConfig struct {
|
||||||
|
data map[string]models.JSONMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockGlobalConfig) Get(_ context.Context, key string) (models.JSONMap, error) {
|
||||||
|
if v, ok := m.data[key]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalConfig) Set(_ context.Context, _ string, _ models.JSONMap, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalConfig) GetAll(_ context.Context) (map[string]models.JSONMap, error) {
|
||||||
|
return m.data, nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalConfig) SaveOIDCState(_ context.Context, _, _, _ string) error { return nil }
|
||||||
|
func (m *mockGlobalConfig) ConsumeOIDCState(_ context.Context, _ string) (string, string, error) {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalConfig) CleanupOIDCState(_ context.Context) error { return nil }
|
||||||
|
func (m *mockGlobalConfig) GetString(_ context.Context, _ string) (string, error) { return "", nil }
|
||||||
|
|
||||||
|
func newMockGC(session models.JSONMap) store.GlobalConfigStore {
|
||||||
|
return &mockGlobalConfig{data: map[string]models.JSONMap{"session": session}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parseDurationString tests ──────────────
|
||||||
|
|
||||||
|
func TestParseDurationString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want time.Duration
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{"15m", 15 * time.Minute, false},
|
||||||
|
{"2h", 2 * time.Hour, false},
|
||||||
|
{"7d", 7 * 24 * time.Hour, false},
|
||||||
|
{"90d", 90 * 24 * time.Hour, false},
|
||||||
|
{"30m", 30 * time.Minute, false},
|
||||||
|
{"1h30m", 90 * time.Minute, false},
|
||||||
|
{"", 0, true},
|
||||||
|
{"abc", 0, true},
|
||||||
|
{"d", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got, err := parseDurationString(tt.input)
|
||||||
|
if tt.err {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for %q, got %v", tt.input, got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for %q: %v", tt.input, err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("parseDurationString(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LoadSessionConfig tests ────────────────
|
||||||
|
|
||||||
|
func TestSessionConfigDefaults(t *testing.T) {
|
||||||
|
gc := &mockGlobalConfig{data: map[string]models.JSONMap{}}
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.AccessTokenTTL != 15*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL = %v, want 15m", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 7*24*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL = %v, want 7d", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 0 {
|
||||||
|
t.Errorf("IdleTimeout = %v, want 0", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigCustomValues(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "30m",
|
||||||
|
"refresh_token_ttl": "30d",
|
||||||
|
"idle_timeout": "1h",
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.AccessTokenTTL != 30*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL = %v, want 30m", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 30*24*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL = %v, want 30d", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 1*time.Hour {
|
||||||
|
t.Errorf("IdleTimeout = %v, want 1h", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigClampLow(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "1m", // below 5m min
|
||||||
|
"refresh_token_ttl": "30m", // below 1h min
|
||||||
|
"idle_timeout": "1m", // below 5m min
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.AccessTokenTTL != 5*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL clamped = %v, want 5m", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 1*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL clamped = %v, want 1h", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 5*time.Minute {
|
||||||
|
t.Errorf("IdleTimeout clamped = %v, want 5m", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigClampHigh(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "2h", // above 60m max
|
||||||
|
"refresh_token_ttl": "365d", // above 90d max
|
||||||
|
"idle_timeout": "48h", // above 24h max
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.AccessTokenTTL != 60*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL clamped = %v, want 60m", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 90*24*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL clamped = %v, want 90d", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 24*time.Hour {
|
||||||
|
t.Errorf("IdleTimeout clamped = %v, want 24h", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigEmptyIdleTimeout(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "15m",
|
||||||
|
"refresh_token_ttl": "7d",
|
||||||
|
"idle_timeout": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.IdleTimeout != 0 {
|
||||||
|
t.Errorf("IdleTimeout = %v, want 0 (disabled)", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigInvalidValues(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "not-a-duration",
|
||||||
|
"refresh_token_ttl": "xyz",
|
||||||
|
"idle_timeout": "---",
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
// Should fall back to defaults
|
||||||
|
if sc.AccessTokenTTL != 15*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL = %v, want 15m (default)", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 7*24*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL = %v, want 7d (default)", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 0 {
|
||||||
|
t.Errorf("IdleTimeout = %v, want 0 (default)", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -444,6 +444,13 @@ func main() {
|
|||||||
wfScanner.Start()
|
wfScanner.Start()
|
||||||
defer wfScanner.Stop()
|
defer wfScanner.Stop()
|
||||||
|
|
||||||
|
// ── Activity ping (authenticated, rate-limited) ──
|
||||||
|
// Client SDK calls this on user interaction (debounced, max 1/min)
|
||||||
|
// to update last_activity_at for idle-timeout tracking.
|
||||||
|
activityGroup := api.Group("/auth")
|
||||||
|
activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||||
|
activityGroup.POST("/activity", authH.Activity)
|
||||||
|
|
||||||
// ── Protected routes ────────────────────
|
// ── Protected routes ────────────────────
|
||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||||
|
|||||||
@@ -142,9 +142,30 @@ func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) {
|
|||||||
return claims, true
|
return claims, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseJWTIgnoringExpiry parses a JWT and validates the signature but
|
||||||
|
// tolerates an expired token. Used by page-auth middleware so the Go
|
||||||
|
// template can render the page shell even when the access token has
|
||||||
|
// lapsed — the Preact SDK will refresh the token client-side.
|
||||||
|
// Returns (claims, signatureValid). A tampered or unsigned token
|
||||||
|
// returns (nil, false).
|
||||||
|
func parseJWTIgnoringExpiry(tokenString string, jwtSecret string) (*Claims, bool) {
|
||||||
|
claims := &Claims{}
|
||||||
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return []byte(jwtSecret), nil
|
||||||
|
}, jwt.WithoutClaimsValidation())
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return claims, true
|
||||||
|
}
|
||||||
|
|
||||||
// UserIDFromCookie extracts the user ID from the arm_token cookie without
|
// UserIDFromCookie extracts the user ID from the arm_token cookie without
|
||||||
// requiring authentication. Returns "" if no valid token is found.
|
// requiring authentication. Returns "" if no valid token is found.
|
||||||
// Used by unauthenticated routes that want optional user context.
|
// Tolerates expired tokens (signature must be valid) so that user preferences
|
||||||
|
// still apply even when the access token has lapsed.
|
||||||
func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
||||||
cookie, err := c.Cookie("arm_token")
|
cookie, err := c.Cookie("arm_token")
|
||||||
if err != nil || cookie == "" {
|
if err != nil || cookie == "" {
|
||||||
@@ -152,7 +173,11 @@ func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
|||||||
}
|
}
|
||||||
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
|
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
// Accept expired-but-signed token for optional user context
|
||||||
|
claims, ok = parseJWTIgnoringExpiry(cookie, jwtSecret)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return claims.UserID
|
return claims.UserID
|
||||||
}
|
}
|
||||||
|
|||||||
82
server/middleware/auth_test.go
Normal file
82
server/middleware/auth_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testSecret = "test-jwt-secret-key"
|
||||||
|
|
||||||
|
func makeToken(t *testing.T, userID, email string, expiresAt time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
|
||||||
|
ID: "test-jti",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
s, err := token.SignedString([]byte(testSecret))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAndValidateJWT_Valid(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(15*time.Minute))
|
||||||
|
claims, ok := parseAndValidateJWT(tok, testSecret)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected valid token to parse")
|
||||||
|
}
|
||||||
|
if claims.UserID != "u1" {
|
||||||
|
t.Errorf("UserID = %q, want u1", claims.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAndValidateJWT_Expired(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||||
|
_, ok := parseAndValidateJWT(tok, testSecret)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected expired token to be rejected by strict parser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAndValidateJWT_WrongSecret(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(15*time.Minute))
|
||||||
|
_, ok := parseAndValidateJWT(tok, "wrong-secret")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected wrong-secret token to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJWTIgnoringExpiry_Expired(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||||
|
claims, ok := parseJWTIgnoringExpiry(tok, testSecret)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected expired token to be accepted by lenient parser")
|
||||||
|
}
|
||||||
|
if claims.UserID != "u1" {
|
||||||
|
t.Errorf("UserID = %q, want u1", claims.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJWTIgnoringExpiry_WrongSecret(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||||
|
_, ok := parseJWTIgnoringExpiry(tok, "wrong-secret")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected tampered token to be rejected even with lenient parser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJWTIgnoringExpiry_GarbageToken(t *testing.T) {
|
||||||
|
_, ok := parseJWTIgnoringExpiry("not.a.jwt", testSecret)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected garbage token to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,10 +50,17 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try strict validation first (token not expired).
|
||||||
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
||||||
if !ok {
|
if !ok {
|
||||||
redirectToLogin(c, loginPath)
|
// Fallback: accept an expired-but-signed token so the page
|
||||||
return
|
// shell can render and the Preact SDK can refresh client-side.
|
||||||
|
// A tampered/unsigned token still gets rejected.
|
||||||
|
claims, ok = parseJWTIgnoringExpiry(tokenString, cfg.JWTSecret)
|
||||||
|
if !ok {
|
||||||
|
redirectToLogin(c, loginPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if claims.UserID == "" {
|
if claims.UserID == "" {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ import (
|
|||||||
// ErrSystemGroup is returned when attempting to delete a system-sourced group.
|
// ErrSystemGroup is returned when attempting to delete a system-sourced group.
|
||||||
var ErrSystemGroup = errors.New("system groups cannot be deleted")
|
var ErrSystemGroup = errors.New("system groups cannot be deleted")
|
||||||
|
|
||||||
|
// RefreshTokenInfo holds extended metadata for a refresh token row.
|
||||||
|
type RefreshTokenInfo struct {
|
||||||
|
UserID string
|
||||||
|
KeepLogin bool
|
||||||
|
LastActivityAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// STORES — Data Access Layer
|
// STORES — Data Access Layer
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -79,11 +86,13 @@ type UserStore interface {
|
|||||||
ListActiveUserIDs(ctx context.Context) ([]string, error)
|
ListActiveUserIDs(ctx context.Context) ([]string, error)
|
||||||
|
|
||||||
// Refresh tokens
|
// Refresh tokens
|
||||||
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
|
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error
|
||||||
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
|
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
|
||||||
|
GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error)
|
||||||
RevokeRefreshToken(ctx context.Context, tokenHash string) error
|
RevokeRefreshToken(ctx context.Context, tokenHash string) error
|
||||||
RevokeAllRefreshTokens(ctx context.Context, userID string) error
|
RevokeAllRefreshTokens(ctx context.Context, userID string) error
|
||||||
CleanExpiredTokens(ctx context.Context) error
|
CleanExpiredTokens(ctx context.Context) error
|
||||||
|
UpdateRefreshTokenActivity(ctx context.Context, userID string) error
|
||||||
|
|
||||||
// ── CS1 additions ──
|
// ── CS1 additions ──
|
||||||
|
|
||||||
|
|||||||
@@ -142,8 +142,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
|
|||||||
|
|
||||||
// ── Refresh Tokens ──────────────────────────
|
// ── Refresh Tokens ──────────────────────────
|
||||||
|
|
||||||
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
|
||||||
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES ($1, $2, $3, $4, NOW())`,
|
||||||
|
userID, tokenHash, expiresAt, keepLogin)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||||
@@ -151,6 +153,17 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
|
|||||||
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID)
|
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID)
|
||||||
return userID, err
|
return userID, err
|
||||||
}
|
}
|
||||||
|
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
|
||||||
|
var info store.RefreshTokenInfo
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT user_id, COALESCE(keep_login, false), last_activity_at
|
||||||
|
FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
|
||||||
|
tokenHash).Scan(&info.UserID, &info.KeepLogin, &info.LastActivityAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
||||||
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
|
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
|
||||||
return err
|
return err
|
||||||
@@ -163,6 +176,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
|
|||||||
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
|
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
"UPDATE refresh_tokens SET last_activity_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
|
||||||
|
userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────
|
// ── Internal ────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -148,9 +148,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
|
|||||||
|
|
||||||
// ── Refresh Tokens ──────────────────────────
|
// ── Refresh Tokens ──────────────────────────
|
||||||
|
|
||||||
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
|
||||||
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`,
|
_, err := DB.ExecContext(ctx,
|
||||||
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt))
|
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||||
@@ -158,6 +159,23 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
|
|||||||
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID)
|
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID)
|
||||||
return userID, err
|
return userID, err
|
||||||
}
|
}
|
||||||
|
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
|
||||||
|
var info store.RefreshTokenInfo
|
||||||
|
var lastAct *string
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT user_id, COALESCE(keep_login, 0), last_activity_at
|
||||||
|
FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`,
|
||||||
|
tokenHash).Scan(&info.UserID, &info.KeepLogin, &lastAct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastAct != nil {
|
||||||
|
if t, err := time.Parse(timeFmt, *lastAct); err == nil {
|
||||||
|
info.LastActivityAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
||||||
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
|
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
|
||||||
return err
|
return err
|
||||||
@@ -170,6 +188,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
|
|||||||
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
|
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
"UPDATE refresh_tokens SET last_activity_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL",
|
||||||
|
userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────
|
// ── Internal ────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,13 @@
|
|||||||
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
|
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-keep-login {
|
||||||
|
display: flex; align-items: center; gap: 6px; margin-top: 4px;
|
||||||
|
font-size: 0.82rem; color: var(--text-secondary, #999); cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.login-keep-login input[type="checkbox"] { margin: 0; cursor: pointer; }
|
||||||
|
|
||||||
.login-auth-error {
|
.login-auth-error {
|
||||||
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
|
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,15 +36,18 @@ export function createAuth() {
|
|||||||
|
|
||||||
// ── Token persistence ───────────────────────
|
// ── Token persistence ───────────────────────
|
||||||
|
|
||||||
|
let _cookieMaxAge = 604800; // default 7 days, updated from server response
|
||||||
|
|
||||||
function _saveTokens() {
|
function _saveTokens() {
|
||||||
localStorage.setItem(_storageKey, JSON.stringify({
|
localStorage.setItem(_storageKey, JSON.stringify({
|
||||||
accessToken: _accessToken,
|
accessToken: _accessToken,
|
||||||
refreshToken: _refreshToken,
|
refreshToken: _refreshToken,
|
||||||
user: _user,
|
user: _user,
|
||||||
|
cookieMaxAge: _cookieMaxAge,
|
||||||
}));
|
}));
|
||||||
// Cookie sync for Go template page auth
|
// Cookie sync for Go template page auth
|
||||||
if (_accessToken) {
|
if (_accessToken) {
|
||||||
document.cookie = `arm_token=${_accessToken}; path=/; max-age=900; SameSite=Strict`;
|
document.cookie = `arm_token=${_accessToken}; path=/; max-age=${_cookieMaxAge}; SameSite=Strict`;
|
||||||
} else {
|
} else {
|
||||||
document.cookie = 'arm_token=; path=/; max-age=0';
|
document.cookie = 'arm_token=; path=/; max-age=0';
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,7 @@ export function createAuth() {
|
|||||||
_accessToken = saved.accessToken || null;
|
_accessToken = saved.accessToken || null;
|
||||||
_refreshToken = saved.refreshToken || null;
|
_refreshToken = saved.refreshToken || null;
|
||||||
_user = saved.user || null;
|
_user = saved.user || null;
|
||||||
|
if (saved.cookieMaxAge) _cookieMaxAge = saved.cookieMaxAge;
|
||||||
}
|
}
|
||||||
} catch (_) { /* corrupt storage */ }
|
} catch (_) { /* corrupt storage */ }
|
||||||
}
|
}
|
||||||
@@ -81,6 +85,7 @@ export function createAuth() {
|
|||||||
_accessToken = data.access_token;
|
_accessToken = data.access_token;
|
||||||
_refreshToken = data.refresh_token;
|
_refreshToken = data.refresh_token;
|
||||||
_user = data.user;
|
_user = data.user;
|
||||||
|
if (data.refresh_expires_in) _cookieMaxAge = data.refresh_expires_in;
|
||||||
_saveTokens();
|
_saveTokens();
|
||||||
_scheduleRefresh(data.expires_in || 900);
|
_scheduleRefresh(data.expires_in || 900);
|
||||||
}
|
}
|
||||||
@@ -149,8 +154,8 @@ export function createAuth() {
|
|||||||
/**
|
/**
|
||||||
* Login with credentials. Stores tokens, fetches permissions, emits event.
|
* Login with credentials. Stores tokens, fetches permissions, emits event.
|
||||||
*/
|
*/
|
||||||
async login(login, password) {
|
async login(login, password, keepLogin = false) {
|
||||||
const data = await _restClient.post('/api/v1/auth/login', { login, password });
|
const data = await _restClient.post('/api/v1/auth/login', { login, password, keep_login: keepLogin });
|
||||||
_setAuth(data);
|
_setAuth(data);
|
||||||
await _fetchPermissions();
|
await _fetchPermissions();
|
||||||
_emit('auth.login', { user: auth.user }, { localOnly: true });
|
_emit('auth.login', { user: auth.user }, { localOnly: true });
|
||||||
@@ -245,9 +250,30 @@ export function createAuth() {
|
|||||||
|
|
||||||
if (_accessToken) {
|
if (_accessToken) {
|
||||||
_emit('auth.boot', { user: auth.user }, { localOnly: true });
|
_emit('auth.boot', { user: auth.user }, { localOnly: true });
|
||||||
|
auth._startActivityTracking();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Activity Ping (idle-timeout support) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start activity tracking. Sends POST /api/v1/auth/activity
|
||||||
|
* on user interaction, debounced to max once per minute.
|
||||||
|
*/
|
||||||
|
_startActivityTracking() {
|
||||||
|
let lastPing = 0;
|
||||||
|
const INTERVAL = 60_000; // 1 minute
|
||||||
|
const ping = () => {
|
||||||
|
if (!_accessToken) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastPing < INTERVAL) return;
|
||||||
|
lastPing = now;
|
||||||
|
_restClient.post('/api/v1/auth/activity', {}).catch(() => {});
|
||||||
|
};
|
||||||
|
document.addEventListener('click', ping, { passive: true });
|
||||||
|
document.addEventListener('keydown', ping, { passive: true });
|
||||||
|
},
|
||||||
|
|
||||||
// ── Internal (called by rest-client / index.js) ──
|
// ── Internal (called by rest-client / index.js) ──
|
||||||
|
|
||||||
_getToken() { return _accessToken; },
|
_getToken() { return _accessToken; },
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export default function SettingsSection() {
|
|||||||
footer_enabled: !!cfg_.footer?.enabled,
|
footer_enabled: !!cfg_.footer?.enabled,
|
||||||
footer_text: cfg_.footer?.text || '',
|
footer_text: cfg_.footer?.text || '',
|
||||||
package_registry_url: cfg_.package_registry?.url || '',
|
package_registry_url: cfg_.package_registry?.url || '',
|
||||||
|
session_access_ttl: cfg_.session?.access_token_ttl || '15m',
|
||||||
|
session_refresh_ttl: cfg_.session?.refresh_token_ttl || '7d',
|
||||||
|
session_idle_enabled: !!cfg_.session?.idle_timeout,
|
||||||
|
session_idle_timeout: cfg_.session?.idle_timeout || '2h',
|
||||||
});
|
});
|
||||||
setVault(v);
|
setVault(v);
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
@@ -82,6 +86,13 @@ export default function SettingsSection() {
|
|||||||
// Package Registry
|
// Package Registry
|
||||||
await sw.api.admin.settings.update('package_registry', { value: { url: cfg.package_registry_url } });
|
await sw.api.admin.settings.update('package_registry', { value: { url: cfg.package_registry_url } });
|
||||||
|
|
||||||
|
// Session
|
||||||
|
await sw.api.admin.settings.update('session', { value: {
|
||||||
|
access_token_ttl: cfg.session_access_ttl,
|
||||||
|
refresh_token_ttl: cfg.session_refresh_ttl,
|
||||||
|
idle_timeout: cfg.session_idle_enabled ? cfg.session_idle_timeout : '',
|
||||||
|
}});
|
||||||
|
|
||||||
sw.toast('Settings saved', 'success');
|
sw.toast('Settings saved', 'success');
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
@@ -163,6 +174,48 @@ export default function SettingsSection() {
|
|||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section"><h3>Session</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Access token TTL</label>
|
||||||
|
<select value=${cfg.session_access_ttl} onChange=${e => set('session_access_ttl', e.target.value)}>
|
||||||
|
<option value="5m">5 minutes</option>
|
||||||
|
<option value="10m">10 minutes</option>
|
||||||
|
<option value="15m">15 minutes (default)</option>
|
||||||
|
<option value="30m">30 minutes</option>
|
||||||
|
<option value="60m">60 minutes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Refresh token TTL</label>
|
||||||
|
<select value=${cfg.session_refresh_ttl} onChange=${e => set('session_refresh_ttl', e.target.value)}>
|
||||||
|
<option value="1h">1 hour</option>
|
||||||
|
<option value="12h">12 hours</option>
|
||||||
|
<option value="24h">24 hours</option>
|
||||||
|
<option value="7d">7 days (default)</option>
|
||||||
|
<option value="30d">30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted" style="font-size:12px;">Access token controls JWT lifetime. Refresh token controls how long sessions persist. "Keep me logged in" unchecked caps refresh at 24h.</span>
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<label class="toggle-label"><input type="checkbox" checked=${cfg.session_idle_enabled} onChange=${e => set('session_idle_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable idle timeout</span></label>
|
||||||
|
${cfg.session_idle_enabled && html`
|
||||||
|
<div class="form-group" style="margin-top:8px;"><label>Idle timeout</label>
|
||||||
|
<select value=${cfg.session_idle_timeout} onChange=${e => set('session_idle_timeout', e.target.value)}>
|
||||||
|
<option value="15m">15 minutes</option>
|
||||||
|
<option value="30m">30 minutes</option>
|
||||||
|
<option value="1h">1 hour</option>
|
||||||
|
<option value="2h">2 hours (default)</option>
|
||||||
|
<option value="4h">4 hours</option>
|
||||||
|
<option value="8h">8 hours</option>
|
||||||
|
<option value="24h">24 hours</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted" style="font-size:12px;">Rejects token refresh if no user activity within the timeout period.</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section"><h3>Encryption Vault</h3>
|
<div class="settings-section"><h3>Encryption Vault</h3>
|
||||||
${vault
|
${vault
|
||||||
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`
|
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const { useState, useRef, useCallback } = hooks;
|
|||||||
export function LoginForm({ onSuccess }) {
|
export function LoginForm({ onSuccess }) {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [keepLogin, setKeepLogin] = useState(false);
|
||||||
const userRef = useRef(null);
|
const userRef = useRef(null);
|
||||||
const passRef = useRef(null);
|
const passRef = useRef(null);
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export function LoginForm({ onSuccess }) {
|
|||||||
setError('');
|
setError('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await sw.auth.login(username, password);
|
await sw.auth.login(username, password, keepLogin);
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else {
|
} else {
|
||||||
@@ -35,7 +36,7 @@ export function LoginForm({ onSuccess }) {
|
|||||||
setError(e.message || 'Login failed');
|
setError(e.message || 'Login failed');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [onSuccess]);
|
}, [onSuccess, keepLogin]);
|
||||||
|
|
||||||
const onUserKey = useCallback((e) => {
|
const onUserKey = useCallback((e) => {
|
||||||
if (e.key === 'Enter') passRef.current?.focus();
|
if (e.key === 'Enter') passRef.current?.focus();
|
||||||
@@ -59,6 +60,11 @@ export function LoginForm({ onSuccess }) {
|
|||||||
placeholder="Enter password" autocomplete="current-password"
|
placeholder="Enter password" autocomplete="current-password"
|
||||||
onKeyDown=${onPassKey} />
|
onKeyDown=${onPassKey} />
|
||||||
</div>
|
</div>
|
||||||
|
<label class="login-keep-login">
|
||||||
|
<input type="checkbox" checked=${keepLogin}
|
||||||
|
onChange=${e => setKeepLogin(e.target.checked)} />
|
||||||
|
<span>Keep me logged in</span>
|
||||||
|
</label>
|
||||||
${error && html`<p class="login-auth-error">${error}</p>`}
|
${error && html`<p class="login-auth-error">${error}</p>`}
|
||||||
<button class="login-btn" disabled=${loading} onClick=${doLogin}>
|
<button class="login-btn" disabled=${loading} onClick=${doLogin}>
|
||||||
${loading ? 'Logging in\u2026' : 'Log In'}
|
${loading ? 'Logging in\u2026' : 'Log In'}
|
||||||
|
|||||||
Reference in New Issue
Block a user