Skip to content

Commit 1b0ef2b

Browse files
dorlugasigalCopilot
andcommitted
feat(tunnel): auth expiry detection, in-app renewal, and token monitoring
Handle DevTunnel auth token expiry gracefully with proactive monitoring and in-app renewal via device code flow. Backend (src/tunnel/index.js): - Detect auth errors ('login required', 'not logged in') in health check - Auth-wait mode: poll every 30s, auto-reconnect when user re-auths - Check isLoggedIn() as fallback for 'Tunnel not found' errors - Token lifetime monitoring: emit auth-expiring when < 1h remaining - Prefer Entra login for new sessions (auto-refreshes for weeks via MSAL) - Warn when logged in with GitHub (8h token limit) - Export parseLoginInfo() and getLoginInfo() for routes - Remove unref() from health check interval (unreliable in PM2) Backend (src/server/index.js): - Wire tunnel events before startTunnel() (fixes missed connected event) - Broadcast tunnel-status WebSocket messages to all clients - Push notifications on auth-expiring and auth-expired - Track tunnelStatus in server state for API endpoint - Detect auth-expired on startup when tunnel fails to start Backend (src/server/routes.js): - GET /api/tunnel/status: returns tunnel state, provider, token lifetime - POST /api/tunnel/renew: spawns devtunnel user login -d, parses device code and URL from output, returns { url, code } as JSON Frontend: - TunnelBanner component with shared Zustand store (tunnelStore.ts) - States: expiring, expired, renewing (shows code + copy/open), renewed, failed - All states have dismiss (✕) button - Renew button on all states (user picks their account on auth page) - 20s fetch timeout for renew endpoint (devtunnel takes up to 15s) - WebSocket tunnel-status forwarding in useTerminalSocket - WSTunnelStatusMessage type added to WebSocket protocol - fetchTunnelStatus() and renewTunnelAuth() API functions Tests: - 22 new tests: parseLoginInfo, device code regex, event contracts, API endpoints, WebSocket broadcast patterns - Updated server.test.js tunnel mock with new exports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 50a9848 commit 1b0ef2b

17 files changed

Lines changed: 991 additions & 43 deletions

File tree

docs/api.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,55 @@ Remove a previously registered push subscription.
975975

976976
---
977977

978+
### Tunnel
979+
980+
#### `GET /api/tunnel/status`
981+
982+
Returns the current tunnel connection state, provider, and token lifetime.
983+
984+
**Response (200):**
985+
986+
```json
987+
{
988+
"state": "connected",
989+
"provider": "microsoft",
990+
"tokenLifetimeSeconds": 3200
991+
}
992+
```
993+
994+
| Field | Type | Description |
995+
| ---------------------- | ----------- | ------------------------------------------------------------------------------------------ |
996+
| `state` | string | Tunnel state: `connected`, `disconnected`, `expiring`, `auth-expired`, or `unknown` |
997+
| `provider` | string/null | Auth provider used for the tunnel: `microsoft`, `github`, or `null` if no tunnel is active |
998+
| `tokenLifetimeSeconds` | number/null | Seconds remaining on the current auth token, or `null` if unavailable |
999+
1000+
---
1001+
1002+
#### `POST /api/tunnel/renew`
1003+
1004+
Initiates a device code authentication flow to renew an expired tunnel token. The client should display the returned URL and code to the user.
1005+
1006+
**Response (200):**
1007+
1008+
```json
1009+
{ "url": "https://microsoft.com/devicelogin", "code": "ABC123" }
1010+
```
1011+
1012+
| Field | Type | Description |
1013+
| ------ | ------ | ----------------------------------------------- |
1014+
| `url` | string | Device login URL the user should open |
1015+
| `code` | string | One-time code the user enters at the device URL |
1016+
1017+
**Response (504):**
1018+
1019+
```json
1020+
{ "error": "Timed out waiting for device code" }
1021+
```
1022+
1023+
Returned when the device code flow does not complete within the expected timeout.
1024+
1025+
---
1026+
9781027
### Port Preview
9791028

9801029
#### `GET /preview/:port/*`
@@ -1140,6 +1189,25 @@ Sent during an in-app update (triggered via `POST /api/update`). Allows the fron
11401189

11411190
The `status` field follows the same values as `GET /api/update/status`. When `status` reaches `restarting`, the WebSocket connection will close shortly after (close code 1012 for non-PM2 installs).
11421191

1192+
#### Tunnel Status
1193+
1194+
Broadcast when the tunnel connection state changes. Allows the frontend to show tunnel health and prompt for re-authentication when tokens expire.
1195+
1196+
```json
1197+
{
1198+
"type": "tunnel-status",
1199+
"state": "expiring",
1200+
"expiresIn": 1800,
1201+
"provider": "microsoft"
1202+
}
1203+
```
1204+
1205+
| Field | Type | Description |
1206+
| ----------- | ------ | -------------------------------------------------------------------------------------------------- |
1207+
| `state` | string | Tunnel state: `connected`, `disconnected`, `expiring`, `auth-expired`, `reconnecting`, or `failed` |
1208+
| `expiresIn` | number | Seconds until the auth token expires (present when `state` is `expiring` or `connected`) |
1209+
| `provider` | string | Auth provider: `microsoft` or `github` (present when a tunnel is active) |
1210+
11431211
---
11441212

11451213
## See Also

docs/architecture.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,12 @@ Manages Azure DevTunnel lifecycle: login, create, host, cleanup. Includes a **wa
118118
- **Zombie detection** — if host connections drop to 0 for two consecutive checks (60s grace), the stale process is killed and a restart is initiated.
119119
- **Crash detection** — an `exit` handler on the child process triggers immediate restart if the process dies.
120120
- **Auto-restart** — exponential backoff (1s → 2s → 5s → 10s → 15s → 30s), up to 10 attempts before giving up.
121+
- **Auth-wait system** — detects auth token expiry (Microsoft limitation), enters an auth-wait mode, polls for re-authentication via device code flow, and auto-reconnects once a fresh token is obtained.
122+
- **Token lifetime monitoring** — tracks the remaining lifetime of the DevTunnel auth token and emits warnings when less than 1 hour remains, giving the frontend time to prompt the user.
121123
- **Event emitter** — exports `tunnelEvents` (EventEmitter) with events: `connected`, `disconnected`, `reconnecting`, `failed`. The server subscribes for logging.
122124

125+
Also exports `getLoginInfo()` (returns current auth provider and token expiry) and `parseLoginInfo()` (parses raw `devtunnel` CLI output into structured login metadata).
126+
123127
### `tunnel/install.js` — DevTunnel Installer
124128

125129
Handles automatic installation of the DevTunnel CLI when it's not found on the system. Prompts the user interactively and installs via the appropriate package manager (brew on macOS, curl on Linux, winget on Windows). Used by `server.js` during startup when tunnel mode is enabled.

docs/security.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,13 @@ The following UI features are entirely client-side and introduce **no new server
176176
- Use `--lan` or `--host 0.0.0.0` to allow LAN access
177177
- The tunnel feature handles TLS via Azure DevTunnels
178178

179+
### Tunnel Token Expiry
180+
181+
- DevTunnel auth tokens expire periodically (a Microsoft-imposed limitation)
182+
- TermBeam detects token expiry and enters **auth-wait mode**, pausing tunnel operations until a fresh token is obtained
183+
- Users can renew the token in-app via a device code flow (`POST /api/tunnel/renew`), which returns a URL and one-time code to complete re-authentication
184+
- Once renewed, the tunnel reconnects automatically — no server restart required
185+
179186
## Best Practices
180187

181188
<!-- prettier-ignore -->

src/frontend/src/components/SessionsHub/SessionsHub.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useThemeStore } from '@/stores/themeStore';
66
import { THEMES, type ThemeId } from '@/themes/terminalThemes';
77
import type { Session } from '@/types';
88
import UpdateBanner from '@/components/common/UpdateBanner';
9+
import TunnelBanner from '@/components/common/TunnelBanner';
910
import SessionCard from './SessionCard';
1011
import NewSessionModal from './NewSessionModal';
1112
import styles from './SessionsHub.module.css';
@@ -166,6 +167,7 @@ export default function SessionsHub() {
166167
return (
167168
<div className={styles.page}>
168169
<UpdateBanner />
170+
<TunnelBanner />
169171

170172
<header className={styles.header}>
171173
<h1 className={styles.title}>

src/frontend/src/components/TerminalApp/TerminalApp.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import NewSessionModal from '@/components/SessionsHub/NewSessionModal';
1818
import { UploadModal } from '@/components/Modals/UploadModal';
1919
import { PreviewModal } from '@/components/Modals/PreviewModal';
2020
import CopyOverlay from '@/components/Overlays/CopyOverlay';
21+
import TunnelBanner from '@/components/common/TunnelBanner';
2122
import type { Session } from '@/types';
2223
import styles from './TerminalApp.module.css';
2324

@@ -449,6 +450,9 @@ export function TerminalApp() {
449450
</div>
450451
)}
451452

453+
{/* ── Tunnel token banner ── */}
454+
<TunnelBanner />
455+
452456
{/* ── Search bar ── */}
453457
<SearchBar />
454458

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
.banner {
2+
display: flex;
3+
align-items: center;
4+
justify-content: center;
5+
gap: 0.75rem;
6+
padding: calc(0.55rem + env(safe-area-inset-top, 0px)) 1rem 0.55rem;
7+
color: #fff;
8+
font-size: 0.82rem;
9+
font-weight: 500;
10+
animation: slide-down 0.25s ease;
11+
}
12+
13+
.warning {
14+
background: #e65100;
15+
}
16+
17+
.error {
18+
background: #d32f2f;
19+
}
20+
21+
.success {
22+
background: #2e7d32;
23+
}
24+
25+
.text {
26+
flex: 1;
27+
text-align: center;
28+
}
29+
30+
.actionBtn {
31+
background: rgba(255, 255, 255, 0.2);
32+
border: 1px solid rgba(255, 255, 255, 0.35);
33+
color: #fff;
34+
font-size: 0.78rem;
35+
font-weight: 600;
36+
cursor: pointer;
37+
padding: 0.25rem 0.65rem;
38+
border-radius: 5px;
39+
transition:
40+
background 0.15s,
41+
opacity 0.15s;
42+
white-space: nowrap;
43+
text-decoration: none;
44+
}
45+
46+
.actionBtn:hover {
47+
background: rgba(255, 255, 255, 0.35);
48+
}
49+
50+
.dismiss {
51+
background: transparent;
52+
border: none;
53+
color: #fff;
54+
font-size: 1rem;
55+
cursor: pointer;
56+
opacity: 0.7;
57+
padding: 0.15rem 0.35rem;
58+
border-radius: 4px;
59+
transition: opacity 0.15s;
60+
line-height: 1;
61+
}
62+
63+
.dismiss:hover {
64+
opacity: 1;
65+
}
66+
67+
@keyframes slide-down {
68+
from {
69+
transform: translateY(-100%);
70+
opacity: 0;
71+
}
72+
to {
73+
transform: translateY(0);
74+
opacity: 1;
75+
}
76+
}

0 commit comments

Comments
 (0)