Skip to content

Commit fdf8e04

Browse files
fix(solid): post-login redirect and refresh token support for Solid OIDC
1 parent 759594e commit fdf8e04

7 files changed

Lines changed: 158 additions & 66 deletions

File tree

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,10 +494,13 @@ APPLE_PRIVATE_KEY_PATH=
494494
APPLE_CALLBACK_URL=/oauth/apple/callback
495495

496496
#SolidOpenID
497+
# For remote IdPs (e.g. solidcommunity.net), use a public app URL for CLIENT_ID and ISSUER; the IdP fetches the client_id URL.
497498
SOLID_OPENID_CLIENT_ID=http://localhost:3080/solid-client-id
499+
SOLID_OPENID_CLIENT_SECRET=
498500
SOLID_OPENID_ISSUER=http://localhost:3000/
499501
SOLID_OPENID_SESSION_SECRET=[JustGenerateARandomSessionSecret]
500-
SOLID_OPENID_SCOPE="openid webid"
502+
# Include offline_access for refresh token support when the IdP supports it (e.g. solidcommunity.net). Local CSS with static client may not return a refresh token.
503+
SOLID_OPENID_SCOPE="openid webid offline_access"
501504
SOLID_OPENID_CALLBACK_URL=/oauth/openid/callback
502505
SOLID_OPENID_BUTTON_LABEL=Continue with Solid
503506

SOLID_INTEGRATION_PROGRESS.md

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ Successfully implemented Solid Pod storage integration for LibreChat, enabling u
139139
- **requireJwtAuth.js**: Removed non–Solid-specific debug logging added during auth flow debugging; reviewer suggested upstreaming useful logging in a separate PR if needed.
140140
- **convos.js**: No code change; reviewer concern addressed by clarification—`getConvo(req.user.id, conversationId, req)` in `Conversation.js` already branches on `isSolidUser(req)` and uses Solid when the user is a Solid user.
141141

142+
### 16. Refresh Token Support & Post-Login Redirect
143+
- **Status**: Complete
144+
- **Details**:
145+
- **Post-login redirect fix**: After Solid OAuth callback, the client calls `/api/auth/refresh`. When the IdP does not return a refresh token (e.g. local Community Solid Server with static client), we now return `{ token, user }` from the **session** (decode id_token for `sub`, find user by openidId, return session token). This prevents the client from redirecting to `/login` when no refresh token is available.
146+
- **Refresh token flow**: When a refresh token *is* present (session or cookie), the refresh controller uses `performOpenIDRefresh` with Solid config and `SOLID_OPENID_SCOPE` to exchange it for new tokens and return `{ token, user }`. Code is ready for IdPs that issue refresh tokens (e.g. solidcommunity.net when properly registered).
147+
- **prompt=consent**: Added `prompt=consent` to the Solid authorization request so that IdPs that support it (e.g. node-oidc-provider) will issue a refresh token when `offline_access` is in scope. Without it, many IdPs do not return a refresh token.
148+
- **JWT strategy by provider**: Solid registers the strategy name `solidJwt`; generic OpenID registers `openidJwt`. Updated `requireJwtAuth.js` and `optionalJwtAuth.js` to use `solidJwt` when `token_provider === 'solid'` and `openidJwt` when `token_provider === 'openid'`, so API requests after Solid login authenticate correctly when only Solid is configured (generic OpenID may be unregistered e.g. due to HTTPS requirement).
149+
- **Token storage**: `setOpenIDAuthTokens` in AuthService stores access, id, and refresh tokens in session; sets `token_provider` cookie from `req.user?.provider` (so Solid gets `token_provider=solid`); only sets the refresh-token cookie when a refresh token exists. No early return when refresh token is missing—access token is always stored so session fallback can work.
150+
- **Findings**:
151+
- **Local CSS (Community Solid Server)** with static client registration (`SOLID_OPENID_CLIENT_ID` / secret from env) does **not** return a refresh token in the token response by default. The server response only includes `access_token`, `id_token`, `expires_in`, `scope`, `token_type`. Adding `prompt=consent` and `scope=openid webid offline_access` is correct and required for IdPs that do support refresh tokens; for local CSS the static client may need to be explicitly configured on the server to allow `refresh_token` grant and `offline_access` scope.
152+
- **solidcommunity.net** (remote IdP): The IdP **dereferences the client_id URL** to fetch client metadata. If `SOLID_OPENID_CLIENT_ID` is a localhost URL (e.g. `http://localhost:3080/solid-client-id`), the remote server cannot reach it and returns 500 ("request to http://localhost:3080/solid-client-id failed, reason: connect ECONNREFUSED"). To use solidcommunity.net, the app must be hosted at a **public URL** and `SOLID_OPENID_CLIENT_ID` must be a publicly reachable URL (or a client id issued by their registration process). Redirect URI must also be public.
153+
- **SOLID_OPENID_SCOPE**: Use `"openid webid offline_access"` for refresh token support when the IdP supports it.
154+
142155
## Current Status
143156

144157
### Working Features
@@ -189,6 +202,9 @@ None currently identified.
189202
- Generic "Login with OpenID" button using `OPENID_*` environment variables
190203
- Both buttons share the same authentication flow and `/oauth/openid` route
191204
- Custom SolidIcon component for Solid branding
205+
- **Refresh token**: When the IdP returns a refresh token, it is stored in session (and cookie when present) and used by `/api/auth/refresh` to obtain new tokens. When no refresh token is returned (e.g. local CSS with static client), a session fallback returns the current session token and user so the client does not redirect to `/login`.
206+
- **Authorization request**: Solid flow sends `prompt=consent` and `scope=openid webid offline_access` so IdPs that support it can issue a refresh token.
207+
- **JWT strategies**: Solid uses `solidJwt`, generic OpenID uses `openidJwt`; `requireJwtAuth` and `optionalJwtAuth` select the strategy based on `token_provider` cookie.
192208

193209
### Access Control (ACL)
194210
- Uses manual ACL Turtle format for permission management
@@ -218,11 +234,15 @@ None currently identified.
218234
- `api/server/routes/oauth.js` - Token logging and storage
219235
- `api/server/services/AuthService.js` - Token management
220236
- `api/server/index.js` - Session middleware ordering
221-
- `api/server/controllers/AuthController.js` - Refresh token handling
237+
- `api/server/controllers/AuthController.js` - Refresh token handling; session fallback when no refresh token (decode session token, find user by openidId, return { token, user }); `performOpenIDRefresh` shared helper; Solid vs openid branch by `token_provider`
238+
- `api/server/services/AuthService.js` - setOpenIDAuthTokens: store access/id/refresh in session; set `token_provider` cookie from req.user?.provider; refresh-token cookie only when refresh token present; no early return when refresh token missing
239+
- `api/server/middleware/requireJwtAuth.js` - Use `solidJwt` when token_provider is solid, `openidJwt` when openid (so Solid-only config works)
240+
- `api/server/middleware/optionalJwtAuth.js` - Use `solidJwt` when token_provider is solid, `openidJwt` when openid; support both for OPENID_REUSE_TOKENS
241+
- `api/strategies/SolidOpenidStrategy.js` - Added `prompt=consent` in authorizationRequestParams for refresh token support; removed temporary SOLID_DEBUG_TOKENS logging
222242
- `api/server/middleware/validate/convoAccess.js` - Added Solid storage support for conversation access validation
223243
- `api/server/middleware/buildEndpointOption.js` - Uses storage-agnostic `getConvo(req.user.id, conversationId, req)` to fill missing model (no Solid-specific logic)
224244
- `api/server/middleware/validateMessageReq.js` - Solid storage validation; no MongoDB fallback for Solid users (404 on Solid failure)
225-
- `api/server/middleware/requireJwtAuth.js` - Removed debug auth logging (per PR review)
245+
- `api/server/middleware/requireJwtAuth.js` - Removed debug auth logging (per PR review); use solidJwt/openidJwt by token_provider
226246
- `api/server/routes/config.js` - `openidLoginEnabled: isOpenIdEnabled` only so Solid-only shows one button (per PR review)
227247
- `api/server/routes/messages.js` - Solid message reads return 503 on Solid failure (no MongoDB fallback)
228248
- `api/server/services/Endpoints/agents/initialize.js` - Enhanced model discovery from request body and endpointOption
@@ -236,7 +256,7 @@ None currently identified.
236256
- `packages/data-schemas/src/schema/share.ts` - Added `podUrl` field to `ISharedLink` schema
237257
- `packages/data-schemas/src/types/share.ts` - Added `podUrl` field to `ISharedLink` interface
238258
- `api/server/routes/share.js` - Updated share routes to pass `req` object for Solid storage support
239-
- `api/strategies/SolidOpenidStrategy.js` - Updated to use `SOLID_OPENID_*` environment variables and register as 'openid' strategy
259+
- `api/strategies/SolidOpenidStrategy.js` - Updated to use `SOLID_OPENID_*` environment variables and register as 'solid' strategy; prompt=consent for refresh token support
240260
- `api/strategies/openidStrategy.js` - Generic OpenID strategy for non-Solid OpenID providers
241261
- `api/strategies/index.js` - Exported both `setupSolidOpenId` and `setupOpenId` functions
242262
- `api/server/socialLogins.js` - Added separate configuration functions for Solid and generic OpenID strategies
@@ -257,7 +277,7 @@ The following environment variables are required for the "Login with Solid" butt
257277
- `SOLID_OPENID_CLIENT_ID` - OAuth client ID for Solid authentication
258278
- `SOLID_OPENID_CLIENT_SECRET` - OAuth client secret for Solid authentication
259279
- `SOLID_OPENID_ISSUER` - Solid Pod provider URL (e.g., `http://localhost:3000/`)
260-
- `SOLID_OPENID_SCOPE` - OAuth scopes (typically `"openid webid"`)
280+
- `SOLID_OPENID_SCOPE` - OAuth scopes (use `"openid webid offline_access"` for refresh token support when the IdP supports it)
261281
- `SOLID_OPENID_SESSION_SECRET` - Secret key for session management
262282
- `SOLID_OPENID_CALLBACK_URL` - OAuth callback URL (typically `/oauth/openid/callback`)
263283

@@ -288,6 +308,6 @@ The following environment variables are used for the generic "Login with OpenID"
288308

289309
---
290310

291-
**Report Date**: February 10, 2026
311+
**Report Date**: February 25, 2026
292312

293313

api/server/controllers/AuthController.js

Lines changed: 108 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const {
1818
findUser,
1919
} = require('~/models');
2020
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
21-
const { getOpenIdConfig } = require('~/strategies');
21+
const { getOpenIdConfig, getSolidOpenIdConfig } = require('~/strategies');
2222

2323
const registrationController = async (req, res) => {
2424
try {
@@ -64,73 +64,128 @@ const resetPasswordController = async (req, res) => {
6464
}
6565
};
6666

67-
const refreshController = async (req, res) => {
68-
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
69-
const token_provider = parsedCookies.token_provider;
67+
/**
68+
* Shared OpenID/Solid refresh flow: exchange refresh token for new tokenset, find user, update session, return token and user.
69+
* @param {Object} req - Express request
70+
* @param {Object} res - Express response
71+
* @param {Object} openIdConfig - Issuer config from getOpenIdConfig() or getSolidOpenIdConfig()
72+
* @param {string} refreshToken - Refresh token from session or cookie
73+
* @param {Record<string, string>} [refreshParams] - Optional params for token endpoint (e.g. { scope: process.env.SOLID_OPENID_SCOPE })
74+
* @returns {Promise<boolean>} True if response was sent, false if caller should continue to next handler
75+
*/
76+
async function performOpenIDRefresh(req, res, openIdConfig, refreshToken, refreshParams = {}) {
77+
try {
78+
const tokenset = await openIdClient.refreshTokenGrant(
79+
openIdConfig,
80+
refreshToken,
81+
Object.keys(refreshParams).length ? refreshParams : undefined,
82+
);
83+
const claims = tokenset.claims();
84+
const { user, error, migration } = await findOpenIDUser({
85+
findUser,
86+
email: claims.email,
87+
openidId: claims.sub,
88+
idOnTheSource: claims.oid,
89+
strategyName: 'refreshController',
90+
});
7091

71-
// Handle OpenID users with OPENID_REUSE_TOKENS enabled
72-
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
73-
/** For OpenID users with token reuse, read refresh token from session */
74-
const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken;
92+
logger.debug(
93+
`[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`,
94+
);
7595

76-
if (!refreshToken) {
77-
// Solid provider may not provide refresh tokens - fall back to standard JWT refresh
96+
if (error || !user) {
7897
logger.warn(
79-
'[refreshController] No OpenID refresh token available, falling back to standard refresh',
98+
`[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`,
8099
);
81-
return res.status(200).send('Refresh token not provided');
100+
res.status(401).redirect('/login');
101+
return true;
82102
}
83103

84-
// We have a refresh token, use OpenID refresh flow
85-
try {
86-
const openIdConfig = getOpenIdConfig();
87-
const tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken);
88-
const claims = tokenset.claims();
89-
const { user, error, migration } = await findOpenIDUser({
90-
findUser,
91-
email: claims.email,
104+
if (migration || user.openidId !== claims.sub) {
105+
const reason = migration ? 'migration' : 'openidId mismatch';
106+
await updateUser(user._id.toString(), {
107+
provider: user.provider || 'openid',
92108
openidId: claims.sub,
93-
idOnTheSource: claims.oid,
94-
strategyName: 'refreshController',
95109
});
96-
97-
logger.debug(
98-
`[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`,
110+
logger.info(
111+
`[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`,
99112
);
113+
}
100114

101-
if (error || !user) {
102-
logger.warn(
103-
`[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`,
104-
);
105-
return res.status(401).redirect('/login');
106-
}
115+
const token = setOpenIDAuthTokens(tokenset, req, res, user._id.toString(), refreshToken);
107116

108-
// Handle migration: update user with openidId if found by email without openidId
109-
// Also handle case where user has mismatched openidId (e.g., after database switch)
110-
if (migration || user.openidId !== claims.sub) {
111-
const reason = migration ? 'migration' : 'openidId mismatch';
112-
await updateUser(user._id.toString(), {
113-
provider: 'openid',
114-
openidId: claims.sub,
115-
});
116-
logger.info(
117-
`[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`,
118-
);
119-
}
117+
user.federatedTokens = {
118+
access_token: tokenset.access_token,
119+
id_token: tokenset.id_token,
120+
refresh_token: tokenset.refresh_token ?? refreshToken,
121+
expires_at: claims.exp,
122+
};
123+
124+
res.status(200).send({ token, user });
125+
return true;
126+
} catch (error) {
127+
logger.error('[refreshController] OpenID token refresh error', error);
128+
return false;
129+
}
130+
}
131+
132+
const refreshController = async (req, res) => {
133+
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
134+
const token_provider = parsedCookies.token_provider;
120135

121-
const token = setOpenIDAuthTokens(tokenset, req, res, user._id.toString(), refreshToken);
136+
// Handle OpenID or Solid users with OPENID_REUSE_TOKENS enabled
137+
const useOpenIDRefresh =
138+
(token_provider === 'openid' || token_provider === 'solid') &&
139+
isEnabled(process.env.OPENID_REUSE_TOKENS);
122140

123-
user.federatedTokens = {
124-
access_token: tokenset.access_token,
125-
id_token: tokenset.id_token,
126-
refresh_token: refreshToken,
127-
expires_at: claims.exp,
128-
};
141+
if (useOpenIDRefresh) {
142+
const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken;
129143

130-
return res.status(200).send({ token, user });
131-
} catch (error) {
132-
logger.error('[refreshController] OpenID token refresh error', error);
144+
if (!refreshToken) {
145+
// No refresh token (e.g. Solid IdP didn't return one) but we may have a valid session from the OAuth callback
146+
const sessionToken =
147+
req.session?.openidTokens?.idToken || req.session?.openidTokens?.accessToken;
148+
if (sessionToken) {
149+
try {
150+
const payload = jwt.decode(sessionToken);
151+
const sub = payload?.sub;
152+
if (sub) {
153+
const { user, error } = await findOpenIDUser({
154+
findUser,
155+
email: payload.email,
156+
openidId: sub,
157+
idOnTheSource: payload.oid,
158+
strategyName: 'refreshController',
159+
});
160+
if (!error && user) {
161+
const token = sessionToken;
162+
logger.debug(
163+
'[refreshController] Returning token from session (no refresh token available)',
164+
);
165+
return res.status(200).send({ token, user });
166+
}
167+
}
168+
} catch (err) {
169+
logger.debug('[refreshController] Session token decode/lookup failed', err.message);
170+
}
171+
}
172+
logger.warn(
173+
'[refreshController] No OpenID refresh token available, falling back to standard refresh',
174+
);
175+
return res.status(200).send('Refresh token not provided');
133176
}
177+
178+
const openIdConfig =
179+
token_provider === 'solid' ? getSolidOpenIdConfig() : getOpenIdConfig();
180+
const refreshParams =
181+
token_provider === 'solid' && process.env.SOLID_OPENID_SCOPE
182+
? { scope: process.env.SOLID_OPENID_SCOPE }
183+
: process.env.OPENID_SCOPE
184+
? { scope: process.env.OPENID_SCOPE }
185+
: {};
186+
187+
const sent = await performOpenIDRefresh(req, res, openIdConfig, refreshToken, refreshParams);
188+
if (sent) return;
134189
}
135190

136191
/** For non-OpenID users or OpenID users without OPENID_REUSE_TOKENS, use standard JWT refresh */

api/server/middleware/optionalJwtAuth.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ const optionalJwtAuth = (req, res, next) => {
1616
}
1717
next();
1818
};
19-
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
20-
return passport.authenticate('openidJwt', { session: false }, callback)(req, res, next);
19+
if (
20+
(tokenProvider === 'openid' || tokenProvider === 'solid') &&
21+
isEnabled(process.env.OPENID_REUSE_TOKENS)
22+
) {
23+
const strategy = tokenProvider === 'solid' ? 'solidJwt' : 'openidJwt';
24+
return passport.authenticate(strategy, { session: false }, callback)(req, res, next);
2125
}
2226
passport.authenticate('jwt', { session: false }, callback)(req, res, next);
2327
};

api/server/middleware/requireJwtAuth.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ const requireJwtAuth = (req, res, next) => {
1010
const cookieHeader = req.headers.cookie;
1111
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
1212

13-
// Use OpenID authentication if token provider is OpenID and OPENID_REUSE_TOKENS is enabled
14-
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
15-
return passport.authenticate('openidJwt', { session: false })(req, res, next);
13+
// Use OpenID/Solid JWT authentication when OPENID_REUSE_TOKENS is enabled
14+
if (
15+
(tokenProvider === 'openid' || tokenProvider === 'solid') &&
16+
isEnabled(process.env.OPENID_REUSE_TOKENS)
17+
) {
18+
const strategy = tokenProvider === 'solid' ? 'solidJwt' : 'openidJwt';
19+
return passport.authenticate(strategy, { session: false })(req, res, next);
1620
}
1721

1822
// Default to standard JWT authentication

api/server/services/AuthService.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -538,8 +538,9 @@ const setOpenIDAuthTokens = (tokenset, req, res, userId, existingRefreshToken) =
538538
});
539539
}
540540

541-
/** Small cookie to indicate token provider (required for auth middleware) */
542-
res.cookie('token_provider', 'openid', {
541+
/** Small cookie to indicate token provider (required for auth middleware and refresh flow) */
542+
const tokenProvider = req.user?.provider || 'openid';
543+
res.cookie('token_provider', tokenProvider, {
543544
expires: expirationDate,
544545
httpOnly: true,
545546
secure: shouldUseSecureCookie(),

api/strategies/SolidOpenidStrategy.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
137137
logger.debug('[SolidOpenidStrategy] Generated nonce for federated provider:', nonce);
138138
}
139139

140+
/** Request consent so CSS/node-oidc-provider issues a refresh_token when offline_access is in scope */
141+
if (!params.has('prompt')) {
142+
params.set('prompt', 'consent');
143+
}
144+
140145
return params;
141146
}
142147
}

0 commit comments

Comments
 (0)