From 37c1e24095a04fbcacb03eb41f26c66e1e7f0b96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 04:49:26 +0000 Subject: [PATCH 1/5] Initial plan From 45a11f5a5b1459d530b616456e97c42d40f9efdd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 04:58:00 +0000 Subject: [PATCH 2/5] Add OAuth2 server module with token management APIs Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- services/web/config/settings.defaults.js | 1 + .../src/OAuthPersonalAccessTokenController.js | 111 ++++++++++++++ .../src/OAuthPersonalAccessTokenManager.mjs | 95 ++++++++++++ .../oauth2-server/app/src/Oauth2Server.js | 100 ++++++++++++ .../app/src/Oauth2ServerRouter.mjs | 39 +++++ .../oauth2-server/app/src/SecretsHelper.js | 21 +++ .../oauth2-server/app/src/TokenController.js | 143 ++++++++++++++++++ services/web/modules/oauth2-server/index.mjs | 12 ++ 8 files changed, 522 insertions(+) create mode 100644 services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenController.js create mode 100644 services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs create mode 100644 services/web/modules/oauth2-server/app/src/Oauth2Server.js create mode 100644 services/web/modules/oauth2-server/app/src/Oauth2ServerRouter.mjs create mode 100644 services/web/modules/oauth2-server/app/src/SecretsHelper.js create mode 100644 services/web/modules/oauth2-server/app/src/TokenController.js create mode 100644 services/web/modules/oauth2-server/index.mjs diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index f7a24760a97..8ebc8a02e33 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1063,6 +1063,7 @@ module.exports = { 'authentication/oidc', 'admin-panel', // import after authentication 'template-gallery', + 'oauth2-server', // OAuth2 server implementation ], viewIncludes: {}, diff --git a/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenController.js b/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenController.js new file mode 100644 index 00000000000..d370ea213b0 --- /dev/null +++ b/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenController.js @@ -0,0 +1,111 @@ +import OAuthPersonalAccessTokenManager from './OAuthPersonalAccessTokenManager.mjs' +import logger from '@overleaf/logger' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.js' + +/** + * Get all personal access tokens for the logged-in user + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function getUserPersonalAccessTokens(req, res) { + try { + const userId = SessionManager.getLoggedInUserId(req.session) + + if (!userId) { + return res.status(401).json({ + error: 'Not authenticated', + }) + } + + const tokens = await OAuthPersonalAccessTokenManager.getUserTokens(userId) + + return res.json({ + tokens, + }) + } catch (error) { + logger.error({ err: error }, 'Error getting user personal access tokens') + return res.status(500).json({ + error: 'Internal server error', + }) + } +} + +/** + * Create a new personal access token for the logged-in user + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function createPersonalAccessToken(req, res) { + try { + const userId = SessionManager.getLoggedInUserId(req.session) + + if (!userId) { + return res.status(401).json({ + error: 'Not authenticated', + }) + } + + const token = await OAuthPersonalAccessTokenManager.createToken(userId) + + return res.json({ + token, + message: 'Personal access token created successfully. Please save this token as it will not be shown again.', + }) + } catch (error) { + logger.error({ err: error }, 'Error creating personal access token') + return res.status(500).json({ + error: 'Internal server error', + }) + } +} + +/** + * Delete a personal access token + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function deletePersonalAccessToken(req, res) { + try { + const userId = SessionManager.getLoggedInUserId(req.session) + + if (!userId) { + return res.status(401).json({ + error: 'Not authenticated', + }) + } + + const { token_id } = req.params + + if (!token_id) { + return res.status(400).json({ + error: 'Token ID is required', + }) + } + + const deleted = await OAuthPersonalAccessTokenManager.deleteToken( + userId, + token_id + ) + + if (!deleted) { + return res.status(404).json({ + error: 'Token not found or already deleted', + }) + } + + return res.json({ + message: 'Token deleted successfully', + }) + } catch (error) { + logger.error({ err: error }, 'Error deleting personal access token') + return res.status(500).json({ + error: 'Internal server error', + }) + } +} + +export default { + getUserPersonalAccessTokens, + createPersonalAccessToken, + deletePersonalAccessToken, +} diff --git a/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs b/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs new file mode 100644 index 00000000000..82da25ac949 --- /dev/null +++ b/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs @@ -0,0 +1,95 @@ +import crypto from 'node:crypto' +import { db } from '../../../../app/src/infrastructure/mongodb.js' +import { hashSecret } from './SecretsHelper.js' + +const PERSONAL_ACCESS_TOKEN_PREFIX = 'olpat_' + +/** + * Generate a random token + */ +function generateToken() { + const randomBytes = crypto.randomBytes(32) + return PERSONAL_ACCESS_TOKEN_PREFIX + randomBytes.toString('hex') +} + +/** + * Create a personal access token for a user + * @param {string} userId - The user ID + * @returns {Promise} The generated token + */ +async function createToken(userId) { + const token = generateToken() + const hashedToken = hashSecret(token) + + const tokenData = { + accessToken: hashedToken, + accessTokenPartial: token.slice(-8), + type: 'personal', + oauthApplication_id: null, + user_id: userId, + scope: '*', + createdAt: new Date(), + expiresAt: null, // Personal access tokens don't expire by default + lastUsedAt: null, + } + + await db.oauthAccessTokens.insertOne(tokenData) + + return token +} + +/** + * Get all personal access tokens for a user + * @param {string} userId - The user ID + * @returns {Promise} Array of token objects + */ +async function getUserTokens(userId) { + const tokens = await db.oauthAccessTokens + .find({ + user_id: userId, + type: 'personal', + }) + .toArray() + + return tokens.map(token => ({ + _id: token._id, + accessTokenPartial: token.accessTokenPartial, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + scope: token.scope, + })) +} + +/** + * Delete a personal access token + * @param {string} userId - The user ID + * @param {string} tokenId - The token ID + * @returns {Promise} True if deleted successfully + */ +async function deleteToken(userId, tokenId) { + const result = await db.oauthAccessTokens.deleteOne({ + _id: tokenId, + user_id: userId, + type: 'personal', + }) + + return result.deletedCount > 0 +} + +/** + * Update the last used timestamp for a token + * @param {string} hashedToken - The hashed token + */ +async function updateLastUsed(hashedToken) { + await db.oauthAccessTokens.updateOne( + { accessToken: hashedToken }, + { $set: { lastUsedAt: new Date() } } + ) +} + +export default { + createToken, + getUserTokens, + deleteToken, + updateLastUsed, +} diff --git a/services/web/modules/oauth2-server/app/src/Oauth2Server.js b/services/web/modules/oauth2-server/app/src/Oauth2Server.js new file mode 100644 index 00000000000..d649f6e8911 --- /dev/null +++ b/services/web/modules/oauth2-server/app/src/Oauth2Server.js @@ -0,0 +1,100 @@ +import { db } from '../../../../app/src/infrastructure/mongodb.js' +import { hashSecret, compareSecret } from './SecretsHelper.js' +import OAuthPersonalAccessTokenManager from './OAuthPersonalAccessTokenManager.mjs' + +/** + * Get token information from the database + * @param {string} accessToken - The access token (plain text) + * @returns {Promise} Token information or null if not found + */ +async function getToken(accessToken) { + const hashedToken = hashSecret(accessToken) + + const token = await db.oauthAccessTokens.findOne({ + accessToken: hashedToken, + }) + + if (!token) { + return null + } + + // Update last used timestamp + await OAuthPersonalAccessTokenManager.updateLastUsed(hashedToken) + + return token +} + +/** + * Verify if a token is valid + * @param {string} accessToken - The access token (plain text) + * @returns {Promise} Validation result with isValid and token info + */ +async function verifyToken(accessToken) { + const token = await getToken(accessToken) + + if (!token) { + return { + isValid: false, + reason: 'Token not found', + } + } + + // Check if token has expired + if (token.expiresAt && new Date() > new Date(token.expiresAt)) { + return { + isValid: false, + reason: 'Token expired', + token, + } + } + + // Check if token has an expiry date for access tokens + if (token.accessTokenExpiresAt && new Date() > new Date(token.accessTokenExpiresAt)) { + return { + isValid: false, + reason: 'Access token expired', + token, + } + } + + return { + isValid: true, + token, + } +} + +/** + * Get OAuth application by client ID + * @param {string} clientId - The client ID + * @returns {Promise} Application object or null + */ +async function getApplication(clientId) { + return await db.oauthApplications.findOne({ id: clientId }) +} + +/** + * Validate client credentials + * @param {string} clientId - The client ID + * @param {string} clientSecret - The client secret + * @returns {Promise} True if credentials are valid + */ +async function validateClient(clientId, clientSecret) { + const application = await getApplication(clientId) + + if (!application) { + return false + } + + if (!application.clientSecret) { + return false + } + + return compareSecret(clientSecret, application.clientSecret) +} + +export default { + getToken, + verifyToken, + getApplication, + validateClient, +} diff --git a/services/web/modules/oauth2-server/app/src/Oauth2ServerRouter.mjs b/services/web/modules/oauth2-server/app/src/Oauth2ServerRouter.mjs new file mode 100644 index 00000000000..dedf5781c69 --- /dev/null +++ b/services/web/modules/oauth2-server/app/src/Oauth2ServerRouter.mjs @@ -0,0 +1,39 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import OAuthPersonalAccessTokenController from './OAuthPersonalAccessTokenController.js' +import TokenController from './TokenController.js' + +export default { + apply(webRouter) { + logger.debug({}, 'Oauth2Server router') + + // Health check endpoint + webRouter.get('/ayaka/oauth2-server', (req, res) => { + res.json({ message: 'Dev by ayaka-notes' }) + }) + + // Token verification and information endpoints + webRouter.get('/oauth/token/info', TokenController.checkOAuthToken) + webRouter.get('/oauth/token/details', TokenController.getTokenInfo) + webRouter.post('/oauth/token/validate', TokenController.validateToken) + + // Personal access token management endpoints + webRouter.get( + '/oauth/personal-access-tokens', + AuthenticationController.requireLogin(), + OAuthPersonalAccessTokenController.getUserPersonalAccessTokens + ) + + webRouter.post( + '/oauth/personal-access-tokens', + AuthenticationController.requireLogin(), + OAuthPersonalAccessTokenController.createPersonalAccessToken + ) + + webRouter.delete( + '/oauth/personal-access-tokens/:token_id', + AuthenticationController.requireLogin(), + OAuthPersonalAccessTokenController.deletePersonalAccessToken + ) + }, +} diff --git a/services/web/modules/oauth2-server/app/src/SecretsHelper.js b/services/web/modules/oauth2-server/app/src/SecretsHelper.js new file mode 100644 index 00000000000..2faae1372e6 --- /dev/null +++ b/services/web/modules/oauth2-server/app/src/SecretsHelper.js @@ -0,0 +1,21 @@ +import crypto from 'node:crypto' + +/** + * Hash a secret using SHA-512 + * @param {string} secret - The secret to hash + * @returns {string} The hashed secret + */ +export function hashSecret(secret) { + return crypto.createHash('sha512').update(secret).digest('hex') +} + +/** + * Compare a plain text secret with a hashed secret + * @param {string} plainSecret - The plain text secret + * @param {string} hashedSecret - The hashed secret to compare against + * @returns {boolean} True if the secrets match + */ +export function compareSecret(plainSecret, hashedSecret) { + const hashedPlain = hashSecret(plainSecret) + return hashedPlain === hashedSecret +} diff --git a/services/web/modules/oauth2-server/app/src/TokenController.js b/services/web/modules/oauth2-server/app/src/TokenController.js new file mode 100644 index 00000000000..2d4d29a63cf --- /dev/null +++ b/services/web/modules/oauth2-server/app/src/TokenController.js @@ -0,0 +1,143 @@ +import Oauth2Server from './Oauth2Server.js' +import logger from '@overleaf/logger' + +/** + * Check if an OAuth token is valid + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function checkOAuthToken(req, res) { + try { + const authHeader = req.headers.authorization + + if (!authHeader) { + return res.status(401).json({ + error: 'No authorization header provided', + }) + } + + // Extract token from "Bearer " format + const token = authHeader.replace(/^Bearer\s+/i, '') + + if (!token) { + return res.status(401).json({ + error: 'No token provided', + }) + } + + const verification = await Oauth2Server.verifyToken(token) + + if (!verification.isValid) { + return res.status(401).json({ + valid: false, + reason: verification.reason, + }) + } + + return res.json({ + valid: true, + token: { + type: verification.token.type, + scope: verification.token.scope, + user_id: verification.token.user_id, + createdAt: verification.token.createdAt, + expiresAt: verification.token.expiresAt, + lastUsedAt: verification.token.lastUsedAt, + }, + }) + } catch (error) { + logger.error({ err: error }, 'Error checking OAuth token') + return res.status(500).json({ + error: 'Internal server error', + }) + } +} + +/** + * Get information about an OAuth token + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function getTokenInfo(req, res) { + try { + const authHeader = req.headers.authorization + + if (!authHeader) { + return res.status(401).json({ + error: 'No authorization header provided', + }) + } + + const token = authHeader.replace(/^Bearer\s+/i, '') + + if (!token) { + return res.status(401).json({ + error: 'No token provided', + }) + } + + const tokenInfo = await Oauth2Server.getToken(token) + + if (!tokenInfo) { + return res.status(404).json({ + error: 'Token not found', + }) + } + + return res.json({ + type: tokenInfo.type, + scope: tokenInfo.scope, + user_id: tokenInfo.user_id, + oauthApplication_id: tokenInfo.oauthApplication_id, + createdAt: tokenInfo.createdAt, + expiresAt: tokenInfo.expiresAt, + accessTokenExpiresAt: tokenInfo.accessTokenExpiresAt, + lastUsedAt: tokenInfo.lastUsedAt, + }) + } catch (error) { + logger.error({ err: error }, 'Error getting token info') + return res.status(500).json({ + error: 'Internal server error', + }) + } +} + +/** + * Validate an OAuth token (check format and existence) + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function validateToken(req, res) { + try { + const { token } = req.body + + if (!token) { + return res.status(400).json({ + error: 'No token provided in request body', + }) + } + + const verification = await Oauth2Server.verifyToken(token) + + return res.json({ + valid: verification.isValid, + reason: verification.reason, + token: verification.token ? { + type: verification.token.type, + scope: verification.token.scope, + user_id: verification.token.user_id, + } : null, + }) + } catch (error) { + logger.error({ err: error }, 'Error validating token') + return res.status(500).json({ + error: 'Internal server error', + }) + } +} + +export default { + checkOAuthToken, + getTokenInfo, + validateToken, +} diff --git a/services/web/modules/oauth2-server/index.mjs b/services/web/modules/oauth2-server/index.mjs new file mode 100644 index 00000000000..bbd196f7e05 --- /dev/null +++ b/services/web/modules/oauth2-server/index.mjs @@ -0,0 +1,12 @@ +import Oauth2ServerRouter from './app/src/Oauth2ServerRouter.mjs' + +/** + * @import { WebModule } from "../../types/web-module" + */ + +/** @type {WebModule} */ +const Oauth2ServerModule = { + router: Oauth2ServerRouter, +} + +export default Oauth2ServerModule From 8162690fe492802f68ff6a716c8ca94624a77ae3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 04:59:22 +0000 Subject: [PATCH 3/5] Fix ObjectId handling in token deletion and add documentation Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- services/web/modules/oauth2-server/README.md | 68 +++++++++++++++++++ .../src/OAuthPersonalAccessTokenManager.mjs | 4 +- 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 services/web/modules/oauth2-server/README.md diff --git a/services/web/modules/oauth2-server/README.md b/services/web/modules/oauth2-server/README.md new file mode 100644 index 00000000000..c409ea5282c --- /dev/null +++ b/services/web/modules/oauth2-server/README.md @@ -0,0 +1,68 @@ +# OAuth2 Server Module + +This module provides OAuth2 server functionality for Overleaf, including personal access token management and token verification APIs. + +## Features + +- **Personal Access Token Management**: Create, list, and delete personal access tokens +- **Token Verification**: Verify token validity and get token information +- **Token Validation**: Check if tokens are properly formatted and not expired + +## API Endpoints + +### Health Check +- `GET /ayaka/oauth2-server` - Health check endpoint + +### Token Information & Verification +- `GET /oauth/token/info` - Check if an OAuth token is valid (requires Bearer token in Authorization header) +- `GET /oauth/token/details` - Get detailed information about a token (requires Bearer token in Authorization header) +- `POST /oauth/token/validate` - Validate a token (send token in request body) + +### Personal Access Token Management +- `GET /oauth/personal-access-tokens` - Get all personal access tokens for the logged-in user (requires login) +- `POST /oauth/personal-access-tokens` - Create a new personal access token (requires login) +- `DELETE /oauth/personal-access-tokens/:token_id` - Delete a personal access token (requires login) + +## Usage Examples + +### Verify a Token +```bash +curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/oauth/token/info +``` + +### Create a Personal Access Token +```bash +curl -X POST -H "Cookie: overleaf.sid=YOUR_SESSION" http://localhost:3000/oauth/personal-access-tokens +``` + +### List Personal Access Tokens +```bash +curl -H "Cookie: overleaf.sid=YOUR_SESSION" http://localhost:3000/oauth/personal-access-tokens +``` + +### Delete a Personal Access Token +```bash +curl -X DELETE -H "Cookie: overleaf.sid=YOUR_SESSION" http://localhost:3000/oauth/personal-access-tokens/TOKEN_ID +``` + +## Components + +- **SecretsHelper**: Provides hashing and comparison functions for secrets +- **Oauth2Server**: Core OAuth2 server functionality +- **OAuthPersonalAccessTokenManager**: Manages personal access tokens +- **TokenController**: Handles token verification and information endpoints +- **OAuthPersonalAccessTokenController**: Handles personal access token CRUD operations +- **Oauth2ServerRouter**: Defines all routes for the module + +## Database Schema + +The module uses the `oauthAccessTokens` collection with the following fields: +- `accessToken`: Hashed token (SHA-512) +- `accessTokenPartial`: Last 8 characters of the token (for display) +- `type`: Token type ('personal' for personal access tokens) +- `user_id`: User ID who owns the token +- `oauthApplication_id`: OAuth application ID (null for personal tokens) +- `scope`: Token scope +- `createdAt`: Creation timestamp +- `expiresAt`: Expiration timestamp (null for non-expiring tokens) +- `lastUsedAt`: Last usage timestamp diff --git a/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs b/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs index 82da25ac949..f2e57999e4a 100644 --- a/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs +++ b/services/web/modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager.mjs @@ -1,5 +1,5 @@ import crypto from 'node:crypto' -import { db } from '../../../../app/src/infrastructure/mongodb.js' +import { db, ObjectId } from '../../../../app/src/infrastructure/mongodb.js' import { hashSecret } from './SecretsHelper.js' const PERSONAL_ACCESS_TOKEN_PREFIX = 'olpat_' @@ -68,7 +68,7 @@ async function getUserTokens(userId) { */ async function deleteToken(userId, tokenId) { const result = await db.oauthAccessTokens.deleteOne({ - _id: tokenId, + _id: new ObjectId(tokenId), user_id: userId, type: 'personal', }) From 9f3ba200ed0893ab4a2cc4d1d36f50c73b1fe75b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 05:00:40 +0000 Subject: [PATCH 4/5] Add comprehensive Chinese usage guide for OAuth2 APIs Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- .../web/modules/oauth2-server/USAGE_GUIDE.md | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 services/web/modules/oauth2-server/USAGE_GUIDE.md diff --git a/services/web/modules/oauth2-server/USAGE_GUIDE.md b/services/web/modules/oauth2-server/USAGE_GUIDE.md new file mode 100644 index 00000000000..3b8af1662a9 --- /dev/null +++ b/services/web/modules/oauth2-server/USAGE_GUIDE.md @@ -0,0 +1,383 @@ +# OAuth2 Server API 使用指南 + +这个模块为 Overleaf 提供了 OAuth2 服务器功能,包括个人访问令牌管理和令牌验证 API。 + +## 功能特性 + +根据你的需求,我已经实现了以下核心 API: + +1. **获取令牌信息** - 获取 OAuth 令牌的详细信息 +2. **验证令牌** - 验证令牌是否合法有效 +3. **检查令牌合法性** - 检查令牌格式和过期状态 + +## API 端点说明 + +### 1. 健康检查 +``` +GET /ayaka/oauth2-server +``` +返回服务状态,无需认证。 + +**响应示例:** +```json +{ + "message": "Dev by ayaka-notes" +} +``` + +### 2. 检查 OAuth 令牌是否合法 (Check Token Validity) +``` +GET /oauth/token/info +``` +**请求头:** +``` +Authorization: Bearer YOUR_TOKEN_HERE +``` + +**功能:** 验证令牌是否有效,包括检查令牌是否存在、是否过期。 + +**成功响应 (200):** +```json +{ + "valid": true, + "token": { + "type": "personal", + "scope": "*", + "user_id": "507f1f77bcf86cd799439011", + "createdAt": "2024-01-01T00:00:00.000Z", + "expiresAt": null, + "lastUsedAt": "2024-01-13T00:00:00.000Z" + } +} +``` + +**失败响应 (401):** +```json +{ + "valid": false, + "reason": "Token not found" +} +``` +或 +```json +{ + "valid": false, + "reason": "Token expired" +} +``` + +### 3. 获取令牌详细信息 (Get Token Details) +``` +GET /oauth/token/details +``` +**请求头:** +``` +Authorization: Bearer YOUR_TOKEN_HERE +``` + +**功能:** 获取令牌的完整信息,包括关联的 OAuth 应用等。 + +**成功响应 (200):** +```json +{ + "type": "personal", + "scope": "*", + "user_id": "507f1f77bcf86cd799439011", + "oauthApplication_id": null, + "createdAt": "2024-01-01T00:00:00.000Z", + "expiresAt": null, + "accessTokenExpiresAt": null, + "lastUsedAt": "2024-01-13T00:00:00.000Z" +} +``` + +**失败响应 (404):** +```json +{ + "error": "Token not found" +} +``` + +### 4. 验证令牌格式和状态 (Validate Token) +``` +POST /oauth/token/validate +Content-Type: application/json +``` + +**请求体:** +```json +{ + "token": "olpat_1234567890abcdef..." +} +``` + +**功能:** 验证任意令牌的格式、存在性和有效性,无需在请求头中携带令牌。 + +**成功响应 (200):** +```json +{ + "valid": true, + "reason": null, + "token": { + "type": "personal", + "scope": "*", + "user_id": "507f1f77bcf86cd799439011" + } +} +``` + +**失败响应 (200):** +```json +{ + "valid": false, + "reason": "Token expired", + "token": null +} +``` + +## 个人访问令牌管理 API + +### 5. 获取用户的所有个人访问令牌 +``` +GET /oauth/personal-access-tokens +Cookie: overleaf.sid=YOUR_SESSION_COOKIE +``` + +**功能:** 获取当前登录用户的所有个人访问令牌列表。 + +**响应 (200):** +```json +{ + "tokens": [ + { + "_id": "507f1f77bcf86cd799439011", + "accessTokenPartial": "abcdef12", + "createdAt": "2024-01-01T00:00:00.000Z", + "lastUsedAt": "2024-01-13T00:00:00.000Z", + "scope": "*" + } + ] +} +``` + +### 6. 创建新的个人访问令牌 +``` +POST /oauth/personal-access-tokens +Cookie: overleaf.sid=YOUR_SESSION_COOKIE +``` + +**功能:** 为当前登录用户创建一个新的个人访问令牌。 + +**响应 (200):** +```json +{ + "token": "olpat_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + "message": "Personal access token created successfully. Please save this token as it will not be shown again." +} +``` + +**重要:** 令牌只会在创建时返回一次,请务必保存! + +### 7. 删除个人访问令牌 +``` +DELETE /oauth/personal-access-tokens/:token_id +Cookie: overleaf.sid=YOUR_SESSION_COOKIE +``` + +**功能:** 删除指定的个人访问令牌。 + +**响应 (200):** +```json +{ + "message": "Token deleted successfully" +} +``` + +**失败响应 (404):** +```json +{ + "error": "Token not found or already deleted" +} +``` + +## 使用示例 + +### 示例 1: 验证一个令牌是否合法 + +```bash +#!/bin/bash + +TOKEN="olpat_your_token_here" + +# 检查令牌是否有效 +curl -X GET \ + -H "Authorization: Bearer ${TOKEN}" \ + http://localhost:3000/oauth/token/info +``` + +### 示例 2: 获取令牌详细信息 + +```bash +#!/bin/bash + +TOKEN="olpat_your_token_here" + +# 获取令牌详细信息 +curl -X GET \ + -H "Authorization: Bearer ${TOKEN}" \ + http://localhost:3000/oauth/token/details +``` + +### 示例 3: 验证任意令牌 + +```bash +#!/bin/bash + +# 验证一个令牌(无需在请求头中携带) +curl -X POST \ + -H "Content-Type: application/json" \ + -d '{"token":"olpat_your_token_here"}' \ + http://localhost:3000/oauth/token/validate +``` + +### 示例 4: 创建个人访问令牌 + +```bash +#!/bin/bash + +SESSION_COOKIE="your_session_cookie" + +# 创建新的个人访问令牌 +curl -X POST \ + -H "Cookie: overleaf.sid=${SESSION_COOKIE}" \ + http://localhost:3000/oauth/personal-access-tokens +``` + +### 示例 5: 获取所有个人访问令牌 + +```bash +#!/bin/bash + +SESSION_COOKIE="your_session_cookie" + +# 获取所有令牌 +curl -X GET \ + -H "Cookie: overleaf.sid=${SESSION_COOKIE}" \ + http://localhost:3000/oauth/personal-access-tokens +``` + +### 示例 6: 删除个人访问令牌 + +```bash +#!/bin/bash + +SESSION_COOKIE="your_session_cookie" +TOKEN_ID="507f1f77bcf86cd799439011" + +# 删除令牌 +curl -X DELETE \ + -H "Cookie: overleaf.sid=${SESSION_COOKIE}" \ + http://localhost:3000/oauth/personal-access-tokens/${TOKEN_ID} +``` + +## Python 示例 + +```python +import requests + +class OAuth2Client: + def __init__(self, base_url="http://localhost:3000"): + self.base_url = base_url + self.session = requests.Session() + + def check_token_validity(self, token): + """检查令牌是否合法""" + response = self.session.get( + f"{self.base_url}/oauth/token/info", + headers={"Authorization": f"Bearer {token}"} + ) + return response.json() + + def get_token_details(self, token): + """获取令牌详细信息""" + response = self.session.get( + f"{self.base_url}/oauth/token/details", + headers={"Authorization": f"Bearer {token}"} + ) + return response.json() + + def validate_token(self, token): + """验证令牌""" + response = self.session.post( + f"{self.base_url}/oauth/token/validate", + json={"token": token} + ) + return response.json() + + def create_personal_token(self, session_cookie): + """创建个人访问令牌""" + self.session.cookies.set("overleaf.sid", session_cookie) + response = self.session.post( + f"{self.base_url}/oauth/personal-access-tokens" + ) + return response.json() + + def list_personal_tokens(self, session_cookie): + """列出所有个人访问令牌""" + self.session.cookies.set("overleaf.sid", session_cookie) + response = self.session.get( + f"{self.base_url}/oauth/personal-access-tokens" + ) + return response.json() + + def delete_personal_token(self, session_cookie, token_id): + """删除个人访问令牌""" + self.session.cookies.set("overleaf.sid", session_cookie) + response = self.session.delete( + f"{self.base_url}/oauth/personal-access-tokens/{token_id}" + ) + return response.json() + +# 使用示例 +if __name__ == "__main__": + client = OAuth2Client() + + # 检查令牌是否合法 + result = client.check_token_validity("olpat_your_token_here") + print(f"Token valid: {result.get('valid')}") + + # 验证令牌 + validation = client.validate_token("olpat_your_token_here") + print(f"Validation result: {validation}") +``` + +## 令牌格式 + +个人访问令牌格式:`olpat_` + 64位十六进制字符串 + +示例:`olpat_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef` + +## 安全说明 + +1. **令牌存储**: 令牌在数据库中以 SHA-512 哈希形式存储 +2. **令牌显示**: 为了安全,列表中只显示令牌的最后 8 个字符 +3. **令牌使用**: 每次使用令牌时,系统会更新 `lastUsedAt` 时间戳 +4. **令牌权限**: 个人访问令牌默认具有全部权限 (scope: "*") + +## 错误代码 + +- `400` - 请求格式错误 +- `401` - 未认证或令牌无效 +- `404` - 令牌未找到 +- `500` - 服务器内部错误 + +## 总结 + +你现在拥有完整的 OAuth 令牌管理功能: + +✅ **获取令牌信息** - 使用 `GET /oauth/token/details` +✅ **验证令牌合法性** - 使用 `GET /oauth/token/info` 或 `POST /oauth/token/validate` +✅ **检查令牌有效性** - 所有验证 API 都会检查令牌是否过期 +✅ **个人令牌管理** - 创建、列出、删除个人访问令牌 + +这些 API 提供了你需要的所有功能来管理和验证 OAuth 令牌! From 6f31e38ac6585d892e3e117c09fa34f640c85aae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 13 Jan 2026 05:02:03 +0000 Subject: [PATCH 5/5] Improve Bearer token extraction and add security documentation Co-authored-by: Musicminion <84625273+Musicminion@users.noreply.github.com> --- .../oauth2-server/app/src/SecretsHelper.js | 6 +++++ .../oauth2-server/app/src/TokenController.js | 23 +++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/services/web/modules/oauth2-server/app/src/SecretsHelper.js b/services/web/modules/oauth2-server/app/src/SecretsHelper.js index 2faae1372e6..be75428817d 100644 --- a/services/web/modules/oauth2-server/app/src/SecretsHelper.js +++ b/services/web/modules/oauth2-server/app/src/SecretsHelper.js @@ -2,6 +2,12 @@ import crypto from 'node:crypto' /** * Hash a secret using SHA-512 + * + * Note: SHA-512 is used here for OAuth tokens (not passwords). + * This is consistent with the existing Overleaf OAuth implementation + * and is appropriate for high-entropy random tokens where the token + * itself provides the security, not the hashing algorithm. + * * @param {string} secret - The secret to hash * @returns {string} The hashed secret */ diff --git a/services/web/modules/oauth2-server/app/src/TokenController.js b/services/web/modules/oauth2-server/app/src/TokenController.js index 2d4d29a63cf..7458a6023ed 100644 --- a/services/web/modules/oauth2-server/app/src/TokenController.js +++ b/services/web/modules/oauth2-server/app/src/TokenController.js @@ -1,6 +1,20 @@ import Oauth2Server from './Oauth2Server.js' import logger from '@overleaf/logger' +/** + * Extract bearer token from Authorization header + * @param {string} authHeader - The Authorization header value + * @returns {string|null} The token or null if invalid format + */ +function extractBearerToken(authHeader) { + if (!authHeader) { + return null + } + + const match = authHeader.match(/^Bearer\s+(.+)$/i) + return match ? match[1] : null +} + /** * Check if an OAuth token is valid * @param {Object} req - Express request object @@ -16,12 +30,11 @@ async function checkOAuthToken(req, res) { }) } - // Extract token from "Bearer " format - const token = authHeader.replace(/^Bearer\s+/i, '') + const token = extractBearerToken(authHeader) if (!token) { return res.status(401).json({ - error: 'No token provided', + error: 'Invalid authorization header format. Expected: Bearer ', }) } @@ -68,11 +81,11 @@ async function getTokenInfo(req, res) { }) } - const token = authHeader.replace(/^Bearer\s+/i, '') + const token = extractBearerToken(authHeader) if (!token) { return res.status(401).json({ - error: 'No token provided', + error: 'Invalid authorization header format. Expected: Bearer ', }) }