Skip to content

Commit a8b5890

Browse files
committed
wip
1 parent 60ea1ba commit a8b5890

6 files changed

Lines changed: 309 additions & 41 deletions

File tree

office/.env.example

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,11 @@ IO_BRIDGE_LICENSE_KEY=
3333
## Basic Authentication Realm
3434
IO_BRIDGE_SERVER_AUTH_BASIC_REALM=interop.io
3535

36-
##
36+
## OAuth2 JWT Issuer URI (used by io.Bridge server for token validation)
3737
#IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_ISSUERURI=
3838

39-
##
40-
IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_AUDIENCE=
39+
## OAuth2 JWT Audience (used by io.Bridge server for token validation)
40+
#IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_AUDIENCE=
4141

4242
###############################################################################
4343
## Mesh ##
@@ -122,5 +122,26 @@ VITE_AUTH0_DOMAIN=
122122
VITE_AUTH0_CLIENT_ID=
123123
VITE_AUTH0_AUDIENCE=
124124

125-
##
126-
#VITE_AUTH0_SCOPES=openid email
125+
## Auth0 scopes for the browser app (Optional, default is "openid profile email")
126+
#VITE_AUTH0_SCOPE=openid profile email
127+
128+
###############################################################################
129+
## Desktop Gateway (Optional — falls back to IO_BRIDGE_* then VITE_*)
130+
## Use these to configure the gateway independently from the bridge/browser
131+
###############################################################################
132+
133+
## io.Bridge URL for the gateway mesh connection (falls back to VITE_IO_BRIDGE_URL)
134+
#IO_GATEWAY_BRIDGE_URL=
135+
136+
## Auth0 Issuer URI for the gateway (falls back to IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_ISSUERURI, then https://<VITE_AUTH0_DOMAIN>)
137+
#IO_GATEWAY_AUTH_ISSUERURI=
138+
139+
## Auth0 Client ID for the gateway (falls back to VITE_AUTH0_CLIENT_ID)
140+
#IO_GATEWAY_AUTH_CLIENT_ID=
141+
142+
## Auth0 Audience for the gateway (falls back to IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_AUDIENCE, then VITE_AUTH0_AUDIENCE)
143+
#IO_GATEWAY_AUTH_AUDIENCE=
144+
145+
## Auth0 scopes for the desktop gateway (Optional, default is "openid email offline_access")
146+
## offline_access is required for automatic token refresh
147+
#IO_GATEWAY_AUTH0_SCOPE=openid email offline_access

office/auth0/app.ts

Lines changed: 151 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ServerConfigurer } from "@interopio/gateway-server/web/server";
22
import type { Logger } from "@interopio/gateway/logging/api";
3-
import { randomUUID } from "node:crypto";
3+
import { createHash, randomBytes, randomUUID } from "node:crypto";
44
import { readFileSync } from "node:fs";
55
import { resolve } from "node:path";
66

@@ -13,18 +13,29 @@ export type AuthInfo = {
1313

1414
const successPage = readFileSync(resolve(import.meta.dirname, './success.html')).toString();
1515

16+
// PKCE helpers
17+
function generateCodeVerifier(): string {
18+
return randomBytes(32).toString('base64url');
19+
}
20+
21+
function generateCodeChallenge(verifier: string): string {
22+
return createHash('sha256').update(verifier).digest('base64url');
23+
}
24+
1625
// ---------------------------------------------------------------------------
17-
// Auth0 integration — Implicit Flow with response_mode=form_post
26+
// Auth0 integration — Authorization Code Flow with PKCE
1827
//
1928
// GET /login/auth0 → redirects to Auth0 Universal Login
20-
// POST /login/oauth2/code/auth0 → Auth0 POSTs tokens back here
29+
// POST /login/oauth2/code/auth0 → Auth0 POSTs authorization code back here;
30+
// we exchange it for tokens (including a
31+
// refresh token when offline_access is granted)
2132
// ---------------------------------------------------------------------------
2233
export const app = async (
2334
config: {
2435
logger: Logger,
2536
issuerUrl: string,
2637
clientId: string,
27-
scopes?: string,
38+
scope?: string,
2839
audience?: string,
2940
authInfoResolver: (data: AuthInfo) => void,
3041
},
@@ -33,16 +44,71 @@ export const app = async (
3344
}: ServerConfigurer
3445
) => {
3546
const { logger, issuerUrl, clientId } = config;
36-
const scopes = config.scopes ?? "openid email";
47+
const scopes = config.scope ?? "openid email offline_access";
3748
const loginCallbackPath = "/login/oauth2/code/auth0";
3849
const loginUrlPath = "/login/auth0";
3950

51+
// PKCE state — single-user server, one login at a time
52+
let pendingCodeVerifier: string | undefined;
53+
let pendingRedirectUri: string | undefined;
54+
55+
// Mutable reference to the resolved auth info so we can update the token
56+
let currentAuthInfo: AuthInfo | undefined;
57+
58+
// -----------------------------------------------------------------------
59+
// Token refresh
60+
// -----------------------------------------------------------------------
61+
const refreshAccessToken = async (refreshToken: string) => {
62+
try {
63+
const response = await fetch(`${issuerUrl}/oauth/token`, {
64+
method: 'POST',
65+
headers: { 'Content-Type': 'application/json' },
66+
body: JSON.stringify({
67+
grant_type: 'refresh_token',
68+
client_id: clientId,
69+
refresh_token: refreshToken,
70+
}),
71+
});
72+
73+
if (!response.ok) {
74+
logger.warn(`token refresh failed: ${response.status} ${response.statusText}`);
75+
// Retry in 30 seconds
76+
setTimeout(() => refreshAccessToken(refreshToken), 30_000);
77+
return;
78+
}
79+
80+
const tokens = await response.json() as {
81+
access_token: string;
82+
refresh_token?: string;
83+
expires_in: number;
84+
};
85+
86+
if (currentAuthInfo) {
87+
currentAuthInfo.token = tokens.access_token;
88+
logger.info(`access token refreshed for "${currentAuthInfo.user}"`);
89+
}
90+
91+
// Auth0 may rotate the refresh token — use the new one if provided
92+
scheduleRefresh(tokens.refresh_token ?? refreshToken, tokens.expires_in);
93+
} catch (err) {
94+
logger.warn(`token refresh error: ${err}`);
95+
setTimeout(() => refreshAccessToken(refreshToken), 30_000);
96+
}
97+
};
98+
99+
const scheduleRefresh = (refreshToken: string, expiresIn: number) => {
100+
// Refresh 60 seconds before expiry, minimum 10 seconds
101+
const refreshMs = Math.max((expiresIn - 60) * 1000, 10_000);
102+
logger.info(`scheduling token refresh in ${Math.round(refreshMs / 1000)}s`);
103+
setTimeout(() => refreshAccessToken(refreshToken), refreshMs);
104+
};
105+
40106
logger.info(`auth0 login endpoint registered on [${loginUrlPath}]`);
41107
logger.info(`auth0 callback endpoint registered on [${loginCallbackPath}]`);
42108

43109
handle(
44110
// ---------------------------------------------------------------
45-
// GET /login/auth0 — redirect to Auth0 Universal Login
111+
// GET /login/auth0 — redirect to Auth0 with PKCE challenge
46112
// ---------------------------------------------------------------
47113
{
48114
request: { method: 'GET', path: loginUrlPath },
@@ -51,19 +117,27 @@ export const app = async (
51117

52118
const nonce = randomUUID();
53119
const state = randomUUID();
120+
const codeVerifier = generateCodeVerifier();
121+
const codeChallenge = generateCodeChallenge(codeVerifier);
54122
const redirectUri = `${request.protocol}://${request.host}${loginCallbackPath}`;
55-
logger.info(`initiating Auth0 implicit login flow ${redirectUri}`);
123+
124+
// Store PKCE verifier for the callback
125+
pendingCodeVerifier = codeVerifier;
126+
pendingRedirectUri = redirectUri;
127+
128+
logger.info(`initiating Auth0 authorization code + PKCE flow → ${redirectUri}`);
56129

57130
const authorizeUrl = `${issuerUrl}/authorize?` + new URLSearchParams({
58-
response_type: 'id_token token',
131+
response_type: 'code',
59132
response_mode: 'form_post',
60133
client_id: clientId,
61134
redirect_uri: redirectUri,
62135
scope: scopes,
63136
state,
64137
nonce,
65138
audience: config.audience ?? `io.bridge`,
66-
139+
code_challenge: codeChallenge,
140+
code_challenge_method: 'S256',
67141
}).toString();
68142

69143
logger.info(`redirecting to Auth0: ${authorizeUrl}`);
@@ -73,7 +147,8 @@ export const app = async (
73147
}
74148
},
75149
// ---------------------------------------------------------------
76-
// POST /login/oauth2/code/auth0 — Auth0 posts tokens here
150+
// POST /login/oauth2/code/auth0 — Auth0 posts authorization code;
151+
// exchange it for access, id & refresh tokens
77152
// ---------------------------------------------------------------
78153
{
79154
request: { method: 'POST', path: loginCallbackPath },
@@ -94,22 +169,81 @@ export const app = async (
94169
return;
95170
}
96171

97-
const idToken = formData.get('id_token');
172+
const code = formData.get('code');
173+
if (!code || !pendingCodeVerifier || !pendingRedirectUri) {
174+
logger.warn('missing authorization code or PKCE verifier');
175+
response.setRawStatusCode(400);
176+
await response.body(new TextEncoder().encode(
177+
'Missing authorization code or PKCE verifier. Please retry login.'
178+
));
179+
await response.end();
180+
return;
181+
}
182+
183+
// Exchange the authorization code for tokens
184+
const codeVerifier = pendingCodeVerifier;
185+
const redirectUri = pendingRedirectUri;
186+
pendingCodeVerifier = undefined;
187+
pendingRedirectUri = undefined;
188+
189+
const tokenResponse = await fetch(`${issuerUrl}/oauth/token`, {
190+
method: 'POST',
191+
headers: { 'Content-Type': 'application/json' },
192+
body: JSON.stringify({
193+
grant_type: 'authorization_code',
194+
client_id: clientId,
195+
code_verifier: codeVerifier,
196+
code,
197+
redirect_uri: redirectUri,
198+
}),
199+
});
200+
201+
if (!tokenResponse.ok) {
202+
const errorBody = await tokenResponse.text();
203+
logger.warn(`token exchange failed: ${tokenResponse.status}${errorBody}`);
204+
response.setRawStatusCode(400);
205+
await response.body(new TextEncoder().encode(
206+
`Token exchange failed: ${errorBody}`
207+
));
208+
await response.end();
209+
return;
210+
}
211+
212+
const tokens = await tokenResponse.json() as {
213+
access_token: string;
214+
id_token?: string;
215+
refresh_token?: string;
216+
expires_in: number;
217+
};
218+
98219
let user = 'dev-user';
99220
let username;
100-
if (idToken) {
101-
const payload = JSON.parse(Buffer.from(idToken.split('.')[1], 'base64').toString());
221+
if (tokens.id_token) {
222+
const payload = JSON.parse(Buffer.from(tokens.id_token.split('.')[1], 'base64').toString());
102223
user = payload.email;
103224
username = payload.name;
104225
}
105226
username ??= user;
106227

107-
// set authInfo with the received tokens and user
108-
config.authInfoResolver({
109-
token: formData.get('access_token'),
228+
// Resolve the auth info promise (consumed by gateway mesh config)
229+
const authInfo: AuthInfo = {
230+
token: tokens.access_token,
110231
user,
111232
username
112-
});
233+
};
234+
currentAuthInfo = authInfo;
235+
config.authInfoResolver(authInfo);
236+
237+
// Schedule automatic token refresh if we received a refresh token
238+
if (tokens.refresh_token && tokens.expires_in) {
239+
scheduleRefresh(tokens.refresh_token, tokens.expires_in);
240+
} else {
241+
logger.warn(
242+
'no refresh token received — token will not auto-renew. ' +
243+
'Ensure the Auth0 API has "Allow Offline Access" enabled and the ' +
244+
'"offline_access" scope is requested.'
245+
);
246+
}
113247

114248
response.setRawStatusCode(200);
115249
response.headers.set('Content-Type', 'text/html');

office/auth0/readme.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,10 @@ The API represents the resource that tokens will be issued for — in this case,
1919
- **Identifier (Audience)**: `io.bridge` (this is the logical identifier, not a URL — it must match `IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_AUDIENCE` in your `.env`)
2020
- **Signing Algorithm**: `RS256` (default)
2121
4. Click **Create**.
22-
5. Note the **Identifier** value — this is your **audience**.
22+
5. Go to the **Settings** tab of the newly created API.
23+
6. Enable **Allow Offline Access** — this allows the desktop gateway to request a refresh token (via the `offline_access` scope) so it can automatically renew expired access tokens without requiring the user to log in again.
24+
7. Click **Save**.
25+
8. Note the **Identifier** value — this is your **audience**.
2326

2427
### Configuration mapping
2528

@@ -29,7 +32,7 @@ The API represents the resource that tokens will be issued for — in this case,
2932

3033
## 3. Create a Single-Page Application (SPA)
3134

32-
The SPA application is used by both the **browser platform** (via `@auth0/auth0-react`) and the **desktop gateway** (via the implicit flow callback registered in `auth0/app.ts`).
35+
The SPA application is used by both the **browser platform** (via `@auth0/auth0-react`) and the **desktop gateway** (via the Authorization Code + PKCE flow registered in `auth0/app.ts`).
3336

3437
1. In the Auth0 Dashboard navigate to **Applications → Applications**.
3538
2. Click **+ Create Application**.
@@ -39,6 +42,7 @@ The SPA application is used by both the **browser platform** (via `@auth0/auth0-
3942
4. Click **Create**.
4043
5. Go to the **Settings** tab of the newly created application.
4144
6. Note the **Client ID** and **Domain** — you will need them for `.env`.
45+
7. Go to the **APIs** tab and find the **io.Bridge** API (created in step 2). Enable **User access** (Authorization Code) — this authorizes the application to request tokens for the io.Bridge API on behalf of users.
4246

4347
### 3.1. Configure Callback URLs and Web Origins
4448

@@ -54,7 +58,7 @@ http://localhost:5173,
5458
https://<your-github-pages-domain>
5559
```
5660

57-
- `http://localhost:8385/login/oauth2/code/auth0` — the desktop gateway's Auth0 callback endpoint (implicit flow with `response_mode=form_post`)
61+
- `http://localhost:8385/login/oauth2/code/auth0` — the desktop gateway's Auth0 callback endpoint (authorization code + PKCE with `response_mode=form_post`)
5862
- `http://localhost:5173` — the Vite dev server (browser platform login)
5963
- Add any additional deployed URLs (e.g. GitHub Pages) as needed
6064

@@ -197,9 +201,25 @@ VITE_AUTH0_AUDIENCE=io.bridge
197201
On startup, the **desktop gateway** (`gateway-server.config.ts`) probes the bridge by sending `POST <bridge_url>/api/nodes` with an empty JSON array:
198202
199203
- **2xx response** → no authentication required; the gateway proceeds with the local OS username.
200-
- **401 + `WWW-Authenticate: Bearer`** → authentication is required; the gateway registers the Auth0 login and callback routes (`/login/auth0` and `/login/oauth2/code/auth0`), then opens the login page in the system browser. After the user authenticates, Auth0 posts the tokens back to the callback endpoint. The received access token is then used for the mesh connection to io.Bridge.
204+
- **401 + `WWW-Authenticate: Bearer`** → authentication is required; the gateway starts the Authorization Code + PKCE flow (see below).
201205
- **Other / unreachable** → the gateway logs a warning and proceeds without a token (the mesh will retry once the bridge becomes available).
202206
207+
### Authorization Code + PKCE flow
208+
209+
When authentication is required, the gateway:
210+
211+
1. Generates a **PKCE code verifier** and its SHA-256 **code challenge**.
212+
2. Registers login (`/login/auth0`) and callback (`/login/oauth2/code/auth0`) routes on the local HTTP server.
213+
3. Opens the Auth0 Universal Login page in the system browser with `response_type=code`, `response_mode=form_post`, and the PKCE challenge.
214+
4. After the user authenticates, Auth0 POSTs the **authorization code** back to the callback endpoint.
215+
5. The gateway exchanges the code (plus the PKCE verifier) for **access, ID, and refresh tokens** via `POST <issuerUrl>/oauth/token`.
216+
6. The access token is used immediately for the mesh connection to io.Bridge.
217+
7. A timer is scheduled to **refresh the access token** automatically (60 seconds before expiry) using the refresh token — so the gateway stays connected without requiring the user to log in again.
218+
219+
> **Note:** The `offline_access` scope and the API's **Allow Offline Access** setting (step 2.6) are both required for the refresh token to be issued.
220+
221+
> **Tip:** To test the refresh logic without waiting 24 hours, go to **Applications → APIs → io.Bridge → Settings** and lower both **Token Expiration (Seconds)** and **Token Expiration For Browser Flows (Seconds)** to the same value (e.g. `120` for 2 minutes) — the browser flow lifetime cannot exceed the maximum. The gateway refreshes 60 seconds before expiry. Remember to restore reasonable values afterwards.
222+
203223
The **browser platform** (`App.tsx`) uses `@auth0/auth0-react` with the `IOConnectHome` login type set to `"auth0"`, handling authentication entirely client-side.
204224
205225

office/gateway-server.config.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ catch (e) {
2121
}
2222
}
2323

24-
const BRIDGE_URL = process.env["VITE_IO_BRIDGE_URL"] ?? "https://gw-bridge-examples.interop.io";
24+
const BRIDGE_URL = process.env["IO_GATEWAY_BRIDGE_URL"] ?? process.env["VITE_IO_BRIDGE_URL"] ?? "https://gw-bridge-examples.interop.io";
2525

2626
let authInfoResolver: (data: AuthInfo) => void;
2727
const authInfoPromise: Promise<AuthInfo> = new Promise((resolve) => {
@@ -190,9 +190,9 @@ export default defineConfig({
190190

191191
// 2b. Auth required (or bridge unreachable — fall back to auth if
192192
// configured, so mesh can connect once bridge becomes available).
193-
const issuerUrl = process.env["IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_ISSUERURI"] ?? `https://${process.env["VITE_AUTH0_DOMAIN"]}`;
194-
const clientId = process.env["VITE_AUTH0_CLIENT_ID"];
195-
const audience = process.env["IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_AUDIENCE"] ?? process.env["VITE_AUTH0_AUDIENCE"];
193+
const issuerUrl = process.env["IO_GATEWAY_AUTH_ISSUERURI"] ?? process.env["IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_ISSUERURI"] ?? `https://${process.env["VITE_AUTH0_DOMAIN"]}`;
194+
const clientId = process.env["IO_GATEWAY_AUTH_CLIENT_ID"] ?? process.env["VITE_AUTH0_CLIENT_ID"];
195+
const audience = process.env["IO_GATEWAY_AUTH_AUDIENCE"] ?? process.env["IO_BRIDGE_SERVER_AUTH_OAUTH2_JWT_AUDIENCE"] ?? process.env["VITE_AUTH0_AUDIENCE"];
196196

197197
if (!issuerUrl || !clientId || !audience) {
198198
if (authResult === "required") {
@@ -223,7 +223,7 @@ export default defineConfig({
223223
issuerUrl,
224224
clientId,
225225
audience,
226-
scopes: process.env.VITE_AUTH0_SCOPES ?? "openid email",
226+
scope: process.env.IO_GATEWAY_AUTH0_SCOPE,
227227
},
228228
configurer,
229229
);

0 commit comments

Comments
 (0)