From c30e74eca823b0fc95c87057ac9ce849e3c6cd6f Mon Sep 17 00:00:00 2001 From: kernoeb Date: Tue, 13 Jan 2026 17:00:35 +0100 Subject: [PATCH 1/8] feat: add OAuth2 authorization code flow endpoints Add /api/auth/authorize and /api/auth/token endpoints to enable OAuth2 authorization code flow for desktop applications and external clients. - Add oauth2Server config with typed client definitions - Add oauthCodes MongoDB collection with 5-minute TTL - Support configurable redirect URIs per client - Generate access_token and id_token_ex for authenticated users --- api/config/custom-environment-variables.cjs | 3 + api/config/default.cjs | 3 + api/config/type/schema.json | 24 ++++++ api/src/auth/router.ts | 83 +++++++++++++++++++++ api/src/mongo.ts | 9 ++- api/types/index.ts | 8 ++ package-lock.json | 44 +++++------ 7 files changed, 148 insertions(+), 26 deletions(-) diff --git a/api/config/custom-environment-variables.cjs b/api/config/custom-environment-variables.cjs index ebf6cb05..d2d5dc7e 100644 --- a/api/config/custom-environment-variables.cjs +++ b/api/config/custom-environment-variables.cjs @@ -285,6 +285,9 @@ module.exports = { secret: 'OAUTH_LINKEDIN_SECRET' } }, + oauth2Server: { + clients: jsonEnv('OAUTH2_SERVER_CLIENTS') + }, saml2: { sp: jsonEnv('SAML2_SP'), providers: jsonEnv('SAML2_PROVIDERS') diff --git a/api/config/default.cjs b/api/config/default.cjs index 7ebc5b8b..ec2298ca 100644 --- a/api/config/default.cjs +++ b/api/config/default.cjs @@ -362,6 +362,9 @@ module.exports = { secret: '' } }, + oauth2Server: { + clients: [] + }, saml2: { // certsDirectory: './security/saml2', // Accepts all samlify options for service providers https://samlify.js.org/#/sp-configuration diff --git a/api/config/type/schema.json b/api/config/type/schema.json index 7c9d5646..6998608c 100644 --- a/api/config/type/schema.json +++ b/api/config/type/schema.json @@ -431,6 +431,18 @@ } } }, + "oauth2Server": { + "type": "object", + "properties": { + "clients": { + "type": "array", + "items": { + "$ref": "#/$defs/oauth2ServerClient" + }, + "default": [] + } + } + }, "webhooks": { "type": "object", "required": [ @@ -620,6 +632,18 @@ "users": { "type": "string" }, "organizations": { "type": "string" } } + }, + "oauth2ServerClient": { + "type": "object", + "required": ["id", "name", "redirectUris"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "redirectUris": { + "type": "array", + "items": { "type": "string" } + } + } } } } \ No newline at end of file diff --git a/api/src/auth/router.ts b/api/src/auth/router.ts index 8789fb5b..b34bbc75 100644 --- a/api/src/auth/router.ts +++ b/api/src/auth/router.ts @@ -19,6 +19,7 @@ import { publicGlobalProviders, publicSiteProviders } from './providers.ts' import { type OAuthRelayState } from '../oauth/service.ts' import { type Saml2RelayState, getUserAttrs as getSamlUserAttrs } from '../saml2/service.ts' import { randomUUID } from 'node:crypto' +import { nanoid } from 'nanoid' import dayjs from 'dayjs' const debug = Debug('auth') @@ -844,3 +845,85 @@ router.post('/saml2-logout', (req, res) => { res.redirect(logout_url);async }); }) */ + +// OAuth2-like authorize endpoint +router.get('/authorize', async (req, res) => { + const { client_id: clientId, redirect_uri: redirectUri, response_type: responseType, state } = req.query + if (responseType !== 'code') return res.status(400).send('Only response_type=code is supported') + if (!redirectUri || typeof redirectUri !== 'string') return res.status(400).send('Missing redirect_uri') + if (!clientId || typeof clientId !== 'string') return res.status(400).send('Missing client_id') + + const clients = config.oauth2Server?.clients ?? [] + const client = clients.find(c => c.id === clientId) + if (!client) return res.status(400).send('Unknown client_id') + + if (!client.redirectUris.some(uri => redirectUri.startsWith(uri))) { + return res.status(400).send('Invalid redirect_uri') + } + + const user = reqUser(req) + if (!user) { + const loginUrl = new URL(config.publicUrl + '/login') + const authorizeUrl = new URL(req.originalUrl, config.publicUrl + '/') + loginUrl.searchParams.set('redirect', authorizeUrl.href) + return res.redirect(loginUrl.href) + } + + const code = nanoid() + await mongo.oauthCodes.insertOne({ + _id: code, + userId: user.id, + clientId: client.id, + redirectUri, + createdAt: new Date() + }) + + const callbackUrl = new URL(redirectUri) + callbackUrl.searchParams.set('code', code) + if (state && typeof state === 'string') callbackUrl.searchParams.set('state', state) + + for (const [key, value] of Object.entries(req.query)) { + if (!['code', 'state', 'redirect_uri', 'response_type', 'client_id'].includes(key) && typeof value === 'string') { + callbackUrl.searchParams.set(key, value) + } + } + + res.redirect(callbackUrl.href) +}) + +// OAuth2-like token endpoint +router.post('/token', async (req, res) => { + const { code, grant_type: grantType, client_id: clientId } = req.body + if (grantType !== 'authorization_code') return res.status(400).send('Only grant_type=authorization_code is supported') + if (!code) return res.status(400).send('Missing code') + + const oauthCode = await mongo.oauthCodes.findOneAndDelete({ _id: code }) + if (!oauthCode) return res.status(400).send('Invalid or expired code') + + if (clientId && oauthCode.clientId !== clientId) { + return res.status(400).send('Client ID mismatch') + } + + const storage = storages.globalStorage + const user = oauthCode.userId === '_superadmin' ? superadmin : await storage.getUser(oauthCode.userId) + if (!user) return res.status(400).send('User not found') + + const site = await reqSite(req) + const serverSession = initServerSession(req) + await storage.addUserSession(user.id, serverSession) + + const payload = getTokenPayload(user, site) + + const token = await signToken(payload, config.jwtDurations.idToken) + + const exchangeExp = Math.floor(Date.now() / 1000) + config.jwtDurations.exchangeToken + const sessionInfo = { user: user.id, session: serverSession.id, adminMode: payload.adminMode } + const exchangeToken = await signToken(sessionInfo, exchangeExp) + + res.send({ + access_token: token, + id_token_ex: exchangeToken, + token_type: 'Bearer', + expires_in: config.jwtDurations.idToken + }) +}) diff --git a/api/src/mongo.ts b/api/src/mongo.ts index 9c6c5787..4d403821 100644 --- a/api/src/mongo.ts +++ b/api/src/mongo.ts @@ -1,4 +1,4 @@ -import type { Site, Limits, OAuthToken, MemberOverwrite, OrganizationOverwrite, ServerSession, PasswordList } from '#types' +import type { Site, Limits, OAuthToken, MemberOverwrite, OrganizationOverwrite, ServerSession, PasswordList, OAuthCode } from '#types' import type { Avatar } from '#services' import type { OrgInDb, UserInDb } from './storages/mongo.ts' @@ -46,6 +46,10 @@ export class SdMongo { return mongo.db.collection('oauth-tokens') } + get oauthCodes () { + return mongo.db.collection('oauth-codes') + } + get oauthRelayStates () { return mongo.db.collection('oauth-relay-states') } @@ -132,6 +136,9 @@ export class SdMongo { 'oauth-tokens-offline': { offlineRefreshToken: 1 }, 'oauth-tokens-sid': { 'token.session_state': 1 } }, + 'oauth-codes': { + ttl: [{ createdAt: 1 }, { expireAfterSeconds: 60 * 5 }] + }, 'oauth-relay-states': { ttl: [{ createdAt: 1 }, { expireAfterSeconds: 24 * 60 * 60 }] }, diff --git a/api/types/index.ts b/api/types/index.ts index fba55c01..c2f7da96 100644 --- a/api/types/index.ts +++ b/api/types/index.ts @@ -24,6 +24,14 @@ export type OAuthToken = { loggedOut?: Date } +export type OAuthCode = { + _id: string + userId: string + redirectUri: string + clientId: string + createdAt: Date +} + export type PublicAuthProvider = { type: string, id: string, diff --git a/package-lock.json b/package-lock.json index 3a5df5e6..6e9d2d26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -702,7 +702,6 @@ "resolved": "https://registry.npmjs.org/@data-fair/lib-node/-/lib-node-2.10.2.tgz", "integrity": "sha512-PAyVkLS0WgsfVPSU+UG6HPcrj00MQbVAVz51/Js9QuDF+FDpjL0gC9hH3EPDN+a+G/ldiG8G3y5tc7S6ckP3xw==", "license": "MIT", - "peer": true, "dependencies": { "@data-fair/lib-common-types": "^1.8.4", "@data-fair/lib-utils": "^1.1.0", @@ -774,6 +773,20 @@ } } }, + "node_modules/@data-fair/lib-types-builder/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@data-fair/lib-utils": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@data-fair/lib-utils/-/lib-utils-1.6.1.tgz", @@ -795,7 +808,6 @@ "resolved": "https://registry.npmjs.org/@data-fair/lib-vue/-/lib-vue-1.24.2.tgz", "integrity": "sha512-yHptHJy9mwK1BacwlrzYXdPRU+vLHOf7dbM7x3ZlrXEExPQFy8dKQYoBxnHuWFGT0Z/kZJqxWcfHRW1Hbr3r8Q==", "license": "MIT", - "peer": true, "dependencies": { "@data-fair/lib-common-types": "^1.7.1", "@data-fair/lib-utils": "^1.0.0", @@ -1786,7 +1798,6 @@ "resolved": "https://registry.npmjs.org/@json-layout/vocabulary/-/vocabulary-2.3.3.tgz", "integrity": "sha512-M2IqHnPcpKUAOrFSSH7Nv7yu9nkQll8R4kPcz5FRWE223rpg9KYV5cmTXzZ0HAglN949I48a2u8l4ua2PknKpQ==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.17.1", "ajv-errors": "^3.0.0", @@ -1826,7 +1837,6 @@ "resolved": "https://registry.npmjs.org/@koumoul/vjsf-compiler/-/vjsf-compiler-1.2.3.tgz", "integrity": "sha512-fsN0+paFY3v0iZk/O4mPREKWInkAnrLBBfqgcoSxIgworq77t/J8AbCby3lFtwSChrEZ4lUBEgfJ0drxe5wN2w==", "license": "MIT", - "peer": true, "dependencies": { "@json-layout/core": "^2.0.0", "@json-layout/vocabulary": "^2.8.0", @@ -1858,7 +1868,6 @@ "resolved": "https://registry.npmjs.org/@json-layout/vocabulary/-/vocabulary-2.8.0.tgz", "integrity": "sha512-7jJ/719ksvlz+BKTBy+2HlQGxmfS2xmFCQvWlP4lOxvy8R+E/YAfR4n73W0qFl4B/F8f6YrBYIszhaVsPXhs4A==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.17.1", "ajv-errors": "^3.0.0", @@ -2587,7 +2596,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz", "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -2801,7 +2809,6 @@ "version": "8.9.0", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.9.0", "@typescript-eslint/types": "8.9.0", @@ -3536,7 +3543,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3566,7 +3572,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4451,7 +4456,6 @@ "version": "9.0.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4628,8 +4632,7 @@ }, "node_modules/dayjs": { "version": "1.11.13", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/de-indent": { "version": "1.0.2", @@ -4726,7 +4729,8 @@ }, "node_modules/destr": { "version": "2.0.3", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/destroy": { "version": "1.2.0", @@ -5235,7 +5239,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6479,7 +6482,6 @@ "version": "4.4.5", "hasInstallScript": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.0" }, @@ -8309,7 +8311,8 @@ }, "node_modules/node-fetch-native": { "version": "1.6.4", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/node-fetch/node_modules/tr46": { "version": "0.0.3", @@ -9565,7 +9568,6 @@ "node_modules/rollup": { "version": "4.24.0", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.6" }, @@ -10612,7 +10614,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10674,7 +10675,6 @@ "version": "5.0.0", "devOptional": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -10826,7 +10826,6 @@ "node_modules/typescript": { "version": "5.6.3", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11219,7 +11218,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -11300,7 +11298,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.13", "@vue/compiler-sfc": "3.5.13", @@ -11386,7 +11383,6 @@ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.8.tgz", "integrity": "sha512-mIjy4utxMz9lMMo6G9vYePv7gUFt4ztOMhY9/4czDJxZ26xPeJ49MAGa9wBAE3XuXbYCrtVPmPxNjej7JJJkZQ==", "license": "MIT", - "peer": true, "dependencies": { "@intlify/core-base": "10.0.8", "@intlify/shared": "10.0.8", @@ -11405,7 +11401,6 @@ "node_modules/vue-router": { "version": "4.4.5", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^6.6.4" }, @@ -11436,7 +11431,6 @@ "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.7.13.tgz", "integrity": "sha512-4+RuQU+zLtXhlN2eZUpKXums9ftzUzhMeiNEJvvJY4XdOzVwUCth2dTnEZkSF6EKdLHk3WhtRk0cIWXZxpBvcw==", "license": "MIT", - "peer": true, "engines": { "node": "^12.20 || >=14.13" }, From 1c3aaef68dd468fac840cf4444769ee2310c621e Mon Sep 17 00:00:00 2001 From: kernoeb Date: Tue, 13 Jan 2026 17:52:24 +0100 Subject: [PATCH 2/8] test: add integration tests for OAuth2 Authorization Code flow --- test-it/oauth2-authorization-code.ts | 122 +++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 test-it/oauth2-authorization-code.ts diff --git a/test-it/oauth2-authorization-code.ts b/test-it/oauth2-authorization-code.ts new file mode 100644 index 00000000..c27f667e --- /dev/null +++ b/test-it/oauth2-authorization-code.ts @@ -0,0 +1,122 @@ +process.env.STORAGE_TYPE = 'mongo' +process.env.OAUTH2_SERVER_CLIENTS = JSON.stringify([{ + id: 'native-app-client', + name: 'Native App Client', + redirectUris: ['native-app://auth-callback'] +}]) + +import { strict as assert } from 'node:assert' +import { it, describe, before, beforeEach, after } from 'node:test' +import { axios, clean, startApiServer, stopApiServer, createUser } from './utils/index.ts' + +describe('OAuth2 Authorization Code Flow', () => { + before(startApiServer) + beforeEach(async () => await clean()) + after(stopApiServer) + + it('should implement Authorization Code flow for native apps', async () => { + // 1. Create a user + const { ax, user } = await createUser('native-test@test.com') + + // 2. Call /authorize endpoint (simulating browser request with active session) + // The user is already logged in via `ax` (which has cookies) + const authorizeUrl = '/api/auth/authorize?response_type=code&client_id=native-app-client&redirect_uri=native-app://auth-callback' + + const authorizeRes = await ax.get(authorizeUrl, { + maxRedirects: 0, + validateStatus: (status) => status === 302 + }) + + // 3. Verify redirect to custom scheme with code + const redirectUrl = new URL(authorizeRes.headers.location) + assert.equal(redirectUrl.protocol, 'native-app:') + assert.equal(redirectUrl.host, 'auth-callback') + + const code = redirectUrl.searchParams.get('code') + assert.ok(code, 'Authorization code should be present') + + // 4. Exchange code for token (simulating native app request) + // This request comes from the app, so it doesn't have the browser session cookies + const appAx = await axios() + + const tokenRes = await appAx.post('/api/auth/token', { + grant_type: 'authorization_code', + code, + client_id: 'native-app-client' + }) + + assert.equal(tokenRes.status, 200) + assert.ok(tokenRes.data.access_token, 'Access token should be present') + assert.ok(tokenRes.data.id_token_ex, 'Exchange token should be present') + + // 5. Verify the access token is valid and belongs to the user + // The access_token is a JWT in this implementation (parts 0 and 1 + sign part 2) + const tokenParts = tokenRes.data.access_token.split('.') + assert.equal(tokenParts.length, 3) + + const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()) + assert.equal(payload.id, user.id) + assert.equal(payload.email, 'native-test@test.com') + + // 6. Security Check: Verify code replay prevention + // Try to use the same code again -> should fail + try { + await appAx.post('/api/auth/token', { + grant_type: 'authorization_code', + code, + client_id: 'native-app-client' + }) + throw new Error('REPLAY_SUCCESS') // Throw a specific error if it succeeds + } catch (err: any) { + if (err.message === 'REPLAY_SUCCESS') { + assert.fail('Security Vulnerability: Authorization code was successfully reused!') + } + const status = err.response?.status || err.status + assert.equal(status, 400, `Expected 400 but got ${status} (Error: ${err.message})`) + + const data = err.response?.data || err.data || err.message + assert.match(String(data), /Invalid or expired code/) + } + }) + + it('should preserve state parameter for CSRF protection', async () => { + const { ax } = await createUser('native-test-state@test.com') + const state = 'random-csrf-token-123' + + const authorizeUrl = `/api/auth/authorize?response_type=code&client_id=native-app-client&redirect_uri=native-app://auth-callback&state=${state}` + + const authorizeRes = await ax.get(authorizeUrl, { + maxRedirects: 0, + validateStatus: (status) => status === 302 + }) + + const redirectUrl = new URL(authorizeRes.headers.location) + assert.equal(redirectUrl.searchParams.get('state'), state) + }) + + it('should reject invalid client_id', async () => { + const { ax } = await createUser('native-test-2@test.com') + + const authorizeUrl = '/api/auth/authorize?response_type=code&client_id=invalid-client&redirect_uri=native-app://auth-callback' + + const authorizeRes = await ax.get(authorizeUrl, { + maxRedirects: 0, + validateStatus: (status) => status === 400 + }) + + assert.match(authorizeRes.data, /Unknown client_id/) + }) + + it('should reject invalid redirect_uri', async () => { + const { ax } = await createUser('native-test-3@test.com') + + const authorizeUrl = '/api/auth/authorize?response_type=code&client_id=native-app-client&redirect_uri=http://evil.com/callback' + + const authorizeRes = await ax.get(authorizeUrl, { + maxRedirects: 0, + validateStatus: (status) => status === 400 + }) + + assert.match(authorizeRes.data, /Invalid redirect_uri/) + }) +}) From 92952666b2bd98ca296c9d96ed1287013f9c61fc Mon Sep 17 00:00:00 2001 From: kernoeb Date: Tue, 13 Jan 2026 18:54:57 +0100 Subject: [PATCH 3/8] fix: ensure oauth2 authorize redirect respects site context --- api/src/auth/router.ts | 4 ++-- test-it/oauth2-authorization-code.ts | 34 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/api/src/auth/router.ts b/api/src/auth/router.ts index b34bbc75..e136e94b 100644 --- a/api/src/auth/router.ts +++ b/api/src/auth/router.ts @@ -863,8 +863,8 @@ router.get('/authorize', async (req, res) => { const user = reqUser(req) if (!user) { - const loginUrl = new URL(config.publicUrl + '/login') - const authorizeUrl = new URL(req.originalUrl, config.publicUrl + '/') + const loginUrl = new URL(reqSiteUrl(req) + '/simple-directory/login') + const authorizeUrl = new URL(req.originalUrl, reqSiteUrl(req)) loginUrl.searchParams.set('redirect', authorizeUrl.href) return res.redirect(loginUrl.href) } diff --git a/test-it/oauth2-authorization-code.ts b/test-it/oauth2-authorization-code.ts index c27f667e..37152592 100644 --- a/test-it/oauth2-authorization-code.ts +++ b/test-it/oauth2-authorization-code.ts @@ -79,6 +79,40 @@ describe('OAuth2 Authorization Code Flow', () => { } }) + it('should redirect to site-specific login page when accessing via site URL', async () => { + const config = (await import('../api/src/config.ts')).default + + // Create a site + const { ax: adminAx } = await createUser('admin@test.com', true) + const org = (await adminAx.post('/api/organizations', { name: 'Site Org' })).data + + // Determine port from adminAx defaults or process + const port = new URL(adminAx.defaults.baseURL || '').port + const siteHost = `127.0.0.1:${port}` + + const anonymousAx = await axios() + await anonymousAx.post('/api/sites', + { _id: 'test-site', owner: { type: 'organization', id: org.id, name: org.name }, host: siteHost, theme: { primaryColor: '#000000' } }, + { params: { key: config.secretKeys.sites } } + ) + + // Access authorize endpoint via site host + const siteAx = await axios({ baseURL: `http://${siteHost}/simple-directory` }) + + const authorizeUrl = '/api/auth/authorize?response_type=code&client_id=native-app-client&redirect_uri=native-app://auth-callback' + + const res = await siteAx.get(authorizeUrl, { + maxRedirects: 0, + validateStatus: (status) => status === 302 + }) + + const redirectUrl = new URL(res.headers.location) + + // Verify that the login redirect targets the site host (127.0.0.1) and not the default (localhost) + assert.equal(redirectUrl.hostname, '127.0.0.1') + assert.ok(redirectUrl.pathname.includes('/login')) + }) + it('should preserve state parameter for CSRF protection', async () => { const { ax } = await createUser('native-test-state@test.com') const state = 'random-csrf-token-123' From 65ce16b8e518d8994834f4e0d7b325b058318254 Mon Sep 17 00:00:00 2001 From: kernoeb Date: Wed, 14 Jan 2026 00:37:12 +0100 Subject: [PATCH 4/8] feat: replace oauth2 server with simplified external apps auth flow --- api/config/custom-environment-variables.cjs | 4 +- api/config/default.cjs | 4 +- api/config/type/schema.json | 19 +-- api/doc/sites/patch-req-body/schema.js | 4 +- api/doc/sites/post-req-body/schema.js | 2 +- api/src/auth/router.ts | 62 ++++---- api/src/mongo.ts | 9 +- api/types/index.ts | 14 +- api/types/site/schema.js | 40 +++++ test-it/external-apps-authorization.ts | 128 ++++++++++++++++ test-it/oauth2-authorization-code.ts | 156 -------------------- 11 files changed, 216 insertions(+), 226 deletions(-) create mode 100644 test-it/external-apps-authorization.ts delete mode 100644 test-it/oauth2-authorization-code.ts diff --git a/api/config/custom-environment-variables.cjs b/api/config/custom-environment-variables.cjs index d2d5dc7e..37117d10 100644 --- a/api/config/custom-environment-variables.cjs +++ b/api/config/custom-environment-variables.cjs @@ -285,13 +285,11 @@ module.exports = { secret: 'OAUTH_LINKEDIN_SECRET' } }, - oauth2Server: { - clients: jsonEnv('OAUTH2_SERVER_CLIENTS') - }, saml2: { sp: jsonEnv('SAML2_SP'), providers: jsonEnv('SAML2_PROVIDERS') }, + applications: jsonEnv('APPLICATIONS'), oidc: { providers: jsonEnv('OIDC_PROVIDERS') }, diff --git a/api/config/default.cjs b/api/config/default.cjs index ec2298ca..cc4ade79 100644 --- a/api/config/default.cjs +++ b/api/config/default.cjs @@ -362,9 +362,6 @@ module.exports = { secret: '' } }, - oauth2Server: { - clients: [] - }, saml2: { // certsDirectory: './security/saml2', // Accepts all samlify options for service providers https://samlify.js.org/#/sp-configuration @@ -373,6 +370,7 @@ module.exports = { // for identify provider https://samlify.js.org/#/idp-configuration providers: [] }, + applications: [], oidc: { providers: [] }, diff --git a/api/config/type/schema.json b/api/config/type/schema.json index 6998608c..988c8a10 100644 --- a/api/config/type/schema.json +++ b/api/config/type/schema.json @@ -431,17 +431,12 @@ } } }, - "oauth2Server": { - "type": "object", - "properties": { - "clients": { - "type": "array", - "items": { - "$ref": "#/$defs/oauth2ServerClient" - }, - "default": [] - } - } + "applications": { + "type": "array", + "items": { + "$ref": "#/$defs/application" + }, + "default": [] }, "webhooks": { "type": "object", @@ -633,7 +628,7 @@ "organizations": { "type": "string" } } }, - "oauth2ServerClient": { + "application": { "type": "object", "required": ["id", "name", "redirectUris"], "properties": { diff --git a/api/doc/sites/patch-req-body/schema.js b/api/doc/sites/patch-req-body/schema.js index f1136dec..401655b6 100644 --- a/api/doc/sites/patch-req-body/schema.js +++ b/api/doc/sites/patch-req-body/schema.js @@ -3,7 +3,7 @@ import SiteSchema from '#types/site/schema.js' const schema = jsonSchema(SiteSchema) .removeReadonlyProperties() - .pickProperties(['title', 'isAccountMain', 'theme', 'reducedPersonalInfoAtCreation', 'tosMessage', 'mails', 'authMode', 'authOnlyOtherSite', 'authProviders']) + .pickProperties(['title', 'isAccountMain', 'theme', 'reducedPersonalInfoAtCreation', 'tosMessage', 'mails', 'authMode', 'authOnlyOtherSite', 'authProviders', 'applications']) .appendTitle(' patch') .schema @@ -41,7 +41,7 @@ schema.layout = { pt: 'Gestão de usuários', es: 'Gestión de usuarios' }, - children: ['reducedPersonalInfoAtCreation', 'tosMessage', 'authMode', 'authOnlyOtherSite', 'authProviders'] + children: ['reducedPersonalInfoAtCreation', 'tosMessage', 'authMode', 'authOnlyOtherSite', 'authProviders', 'applications'] } ] } diff --git a/api/doc/sites/post-req-body/schema.js b/api/doc/sites/post-req-body/schema.js index 46e99488..2c3a3232 100644 --- a/api/doc/sites/post-req-body/schema.js +++ b/api/doc/sites/post-req-body/schema.js @@ -3,7 +3,7 @@ import SiteSchema from '#types/site/schema.js' const schema = jsonSchema(SiteSchema) .removeReadonlyProperties() - .pickProperties(['_id', 'owner', 'host', 'path', 'tmp', 'title', 'theme']) + .pickProperties(['_id', 'owner', 'host', 'path', 'tmp', 'title', 'theme', 'applications']) .removeFromRequired(['_id', 'theme']) .appendTitle(' post') .schema diff --git a/api/src/auth/router.ts b/api/src/auth/router.ts index e136e94b..c6a02326 100644 --- a/api/src/auth/router.ts +++ b/api/src/auth/router.ts @@ -19,7 +19,6 @@ import { publicGlobalProviders, publicSiteProviders } from './providers.ts' import { type OAuthRelayState } from '../oauth/service.ts' import { type Saml2RelayState, getUserAttrs as getSamlUserAttrs } from '../saml2/service.ts' import { randomUUID } from 'node:crypto' -import { nanoid } from 'nanoid' import dayjs from 'dayjs' const debug = Debug('auth') @@ -846,17 +845,21 @@ router.post('/saml2-logout', (req, res) => { }); }) */ -// OAuth2-like authorize endpoint -router.get('/authorize', async (req, res) => { - const { client_id: clientId, redirect_uri: redirectUri, response_type: responseType, state } = req.query - if (responseType !== 'code') return res.status(400).send('Only response_type=code is supported') +// OAuth2-like authorize endpoint for external apps +router.get('/apps/authorize', async (req, res) => { + const { client_id: clientId, redirect_uri: redirectUri, state } = req.query if (!redirectUri || typeof redirectUri !== 'string') return res.status(400).send('Missing redirect_uri') if (!clientId || typeof clientId !== 'string') return res.status(400).send('Missing client_id') - const clients = config.oauth2Server?.clients ?? [] - const client = clients.find(c => c.id === clientId) + const site = await reqSite(req) + // @ts-ignore + let client = (site?.applications || []).find(c => c.id === clientId) + if (!client && !site) { + client = (config.applications || []).find(c => c.id === clientId) + } if (!client) return res.status(400).send('Unknown client_id') + // @ts-ignore if (!client.redirectUris.some(uri => redirectUri.startsWith(uri))) { return res.status(400).send('Invalid redirect_uri') } @@ -869,19 +872,19 @@ router.get('/authorize', async (req, res) => { return res.redirect(loginUrl.href) } - const code = nanoid() - await mongo.oauthCodes.insertOne({ - _id: code, + const codePayload = { userId: user.id, clientId: client.id, redirectUri, - createdAt: new Date() - }) + type: 'auth_code' + } + const code = await signToken(codePayload, '5m') const callbackUrl = new URL(redirectUri) callbackUrl.searchParams.set('code', code) if (state && typeof state === 'string') callbackUrl.searchParams.set('state', state) + // preserve other query params if needed? (e.g. for desktop app context) for (const [key, value] of Object.entries(req.query)) { if (!['code', 'state', 'redirect_uri', 'response_type', 'client_id'].includes(key) && typeof value === 'string') { callbackUrl.searchParams.set(key, value) @@ -891,21 +894,22 @@ router.get('/authorize', async (req, res) => { res.redirect(callbackUrl.href) }) -// OAuth2-like token endpoint -router.post('/token', async (req, res) => { - const { code, grant_type: grantType, client_id: clientId } = req.body - if (grantType !== 'authorization_code') return res.status(400).send('Only grant_type=authorization_code is supported') +// OAuth2-like login endpoint for external apps (exchanges code for session cookies) +router.post('/apps/login', async (req, res) => { + const { code } = req.body if (!code) return res.status(400).send('Missing code') - const oauthCode = await mongo.oauthCodes.findOneAndDelete({ _id: code }) - if (!oauthCode) return res.status(400).send('Invalid or expired code') - - if (clientId && oauthCode.clientId !== clientId) { - return res.status(400).send('Client ID mismatch') + let decoded + try { + decoded = await session.verifyToken(code) + } catch (err) { + return res.status(400).send('Invalid or expired code') } + if (decoded.type !== 'auth_code') return res.status(400).send('Invalid token type') + const storage = storages.globalStorage - const user = oauthCode.userId === '_superadmin' ? superadmin : await storage.getUser(oauthCode.userId) + const user = decoded.userId === '_superadmin' ? superadmin : await storage.getUser(decoded.userId) if (!user) return res.status(400).send('User not found') const site = await reqSite(req) @@ -914,16 +918,8 @@ router.post('/token', async (req, res) => { const payload = getTokenPayload(user, site) - const token = await signToken(payload, config.jwtDurations.idToken) - - const exchangeExp = Math.floor(Date.now() / 1000) + config.jwtDurations.exchangeToken - const sessionInfo = { user: user.id, session: serverSession.id, adminMode: payload.adminMode } - const exchangeToken = await signToken(sessionInfo, exchangeExp) + await confirmLog(storage, user, serverSession) + await setSessionCookies(req, res, payload, serverSession.id, getDefaultUserOrg(user, site)) - res.send({ - access_token: token, - id_token_ex: exchangeToken, - token_type: 'Bearer', - expires_in: config.jwtDurations.idToken - }) + res.send(user) }) diff --git a/api/src/mongo.ts b/api/src/mongo.ts index 4d403821..9c6c5787 100644 --- a/api/src/mongo.ts +++ b/api/src/mongo.ts @@ -1,4 +1,4 @@ -import type { Site, Limits, OAuthToken, MemberOverwrite, OrganizationOverwrite, ServerSession, PasswordList, OAuthCode } from '#types' +import type { Site, Limits, OAuthToken, MemberOverwrite, OrganizationOverwrite, ServerSession, PasswordList } from '#types' import type { Avatar } from '#services' import type { OrgInDb, UserInDb } from './storages/mongo.ts' @@ -46,10 +46,6 @@ export class SdMongo { return mongo.db.collection('oauth-tokens') } - get oauthCodes () { - return mongo.db.collection('oauth-codes') - } - get oauthRelayStates () { return mongo.db.collection('oauth-relay-states') } @@ -136,9 +132,6 @@ export class SdMongo { 'oauth-tokens-offline': { offlineRefreshToken: 1 }, 'oauth-tokens-sid': { 'token.session_state': 1 } }, - 'oauth-codes': { - ttl: [{ createdAt: 1 }, { expireAfterSeconds: 60 * 5 }] - }, 'oauth-relay-states': { ttl: [{ createdAt: 1 }, { expireAfterSeconds: 24 * 60 * 60 }] }, diff --git a/api/types/index.ts b/api/types/index.ts index c2f7da96..5755dc23 100644 --- a/api/types/index.ts +++ b/api/types/index.ts @@ -16,6 +16,12 @@ export type UserWritable = Omit & Pick +export type Application = { + id: string + name: string + redirectUris: string[] +} + export type OAuthToken = { token: any, provider: { type: string, id: string, title: string }, @@ -24,14 +30,6 @@ export type OAuthToken = { loggedOut?: Date } -export type OAuthCode = { - _id: string - userId: string - redirectUri: string - clientId: string - createdAt: Date -} - export type PublicAuthProvider = { type: string, id: string, diff --git a/api/types/site/schema.js b/api/types/site/schema.js index de5db310..b7d08ff5 100644 --- a/api/types/site/schema.js +++ b/api/types/site/schema.js @@ -311,9 +311,49 @@ export default { de: 'Identitätsanbieter (SSO)' }, items: { $ref: '#/$defs/authProvider' } + }, + applications: { + type: 'array', + title: 'Applications externes', + 'x-i18n-title': { + fr: 'Applications externes', + en: 'External applications' + }, + items: { $ref: '#/$defs/application' } } }, $defs: { + application: { + type: 'object', + required: ['name', 'redirectUris'], + properties: { + id: { + type: 'string', + title: 'Client ID', + readOnly: true, + layout: { cols: 12 } + }, + name: { + type: 'string', + title: 'Nom', + 'x-i18n-title': { + fr: 'Nom', + en: 'Name' + }, + layout: { cols: 6 } + }, + redirectUris: { + type: 'array', + title: 'URLs de redirection', + 'x-i18n-title': { + fr: 'URLs de redirection', + en: 'Redirect URLs' + }, + items: { type: 'string' }, + layout: { cols: 12 } + } + } + }, authProvider: { type: 'object', layout: { diff --git a/test-it/external-apps-authorization.ts b/test-it/external-apps-authorization.ts new file mode 100644 index 00000000..0a82bc16 --- /dev/null +++ b/test-it/external-apps-authorization.ts @@ -0,0 +1,128 @@ +import { strict as assert } from 'node:assert' + +process.env.STORAGE_TYPE = 'mongo' + +import { it, describe, before, beforeEach, after } from 'node:test' +import { axios, clean, startApiServer, stopApiServer, createUser } from './utils/index.ts' + +describe('External Apps Authorization Flow', () => { + before(startApiServer) + beforeEach(async () => await clean()) + after(stopApiServer) + + it('should implement Authorization flow for external apps', async () => { + const config = (await import('../api/src/config.ts')).default + + // 1. Create a site with application configuration + const { ax: adminAx } = await createUser('admin@test.com', true) + const org = (await adminAx.post('/api/organizations', { name: 'Site Org' })).data + + // Determine port + const port = new URL(adminAx.defaults.baseURL || '').port + const siteHost = `127.0.0.1:${port}` + + const anonymousAx = await axios() + await anonymousAx.post('/api/sites', + { + _id: 'test-site', + owner: { type: 'organization', id: org.id, name: org.name }, + host: siteHost, + theme: { primaryColor: '#000000' }, + applications: [{ + id: 'native-app-client', + name: 'Native App', + redirectUris: ['native-app://auth-callback'] + }] + }, + { params: { key: config.secretKeys.sites } } + ) + + // Enable local auth to allow user creation + await adminAx.patch('/api/sites/test-site', { authMode: 'onlyLocal' }); + (await import('../api/src/sites/service.ts')).getSiteByHost.clear() + + // 2. Create a user on the site + const { ax: userSiteAx } = await createUser('native-test@test.com', false, 'TestPasswd01', `http://${siteHost}/simple-directory`) + + // 3. Call /authorize endpoint (simulating browser request with active session) + const authorizeUrl = '/api/auth/apps/authorize?client_id=native-app-client&redirect_uri=native-app://auth-callback' + + const authorizeRes = await userSiteAx.get(authorizeUrl, { + maxRedirects: 0, + validateStatus: (status) => status === 302 + }) + console.log('Redirect location:', authorizeRes.headers.location) + + // 4. Verify redirect to custom scheme with code + const redirectUrl = new URL(authorizeRes.headers.location) + assert.equal(redirectUrl.protocol, 'native-app:') + assert.equal(redirectUrl.host, 'auth-callback') + + const code = redirectUrl.searchParams.get('code') + assert.ok(code, 'Authorization code should be present') + + // 5. Exchange code for session (simulating native app request) + const appAx = await axios({ baseURL: `http://${siteHost}/simple-directory` }) + + const loginRes2 = await appAx.post('/api/auth/apps/login', { + code + }) + + assert.equal(loginRes2.status, 200) + // Check if user is returned + assert.equal(loginRes2.data.email, 'native-test@test.com') + + // Check if cookies are set + const appCookies = loginRes2.headers['set-cookie'] + assert.ok(appCookies, 'Session cookies should be set') + assert.ok(appCookies.some(c => c.startsWith('id_token')), 'id_token should be present') + + // 6. Verify session works + const appSessionAx = await axios({ + baseURL: `http://${siteHost}/simple-directory`, + headers: { Cookie: appCookies } + }) + const meRes = await appSessionAx.get('/api/auth/me') + assert.equal(meRes.status, 200) + assert.equal(meRes.data.email, 'native-test@test.com') + + // 7. Replay Check: Code reuse (stateless JWT) + // Since it's stateless, it CAN be reused within validity period. + // The previous test asserted failure. Here we acknowledge it succeeds (logging in again). + const replayRes = await appAx.post('/api/auth/apps/login', { + code + }) + assert.equal(replayRes.status, 200) + }) + + it('should reject invalid client_id', async () => { + const config = (await import('../api/src/config.ts')).default + const { ax: adminAx } = await createUser('admin@test.com', true) + const org = (await adminAx.post('/api/organizations', { name: 'Site Org 2' })).data + const port = new URL(adminAx.defaults.baseURL || '').port + const siteHost = `127.0.0.1:${port}` + + const anonymousAx = await axios() + await anonymousAx.post('/api/sites', + { + _id: 'test-site-2', + owner: { type: 'organization', id: org.id, name: org.name }, + host: siteHost, + theme: { primaryColor: '#000000' }, + applications: [] + }, + { params: { key: config.secretKeys.sites } } + ) + + const siteAx = await axios({ baseURL: `http://${siteHost}/simple-directory` }) + + const authorizeUrl = '/api/auth/apps/authorize?client_id=invalid-client&redirect_uri=native-app://auth-callback' + + const authorizeRes = await siteAx.get(authorizeUrl, { + maxRedirects: 0, + validateStatus: (status) => status === 400 + }) + + assert.match(authorizeRes.data, /Unknown client_id/) + }) +}) diff --git a/test-it/oauth2-authorization-code.ts b/test-it/oauth2-authorization-code.ts deleted file mode 100644 index 37152592..00000000 --- a/test-it/oauth2-authorization-code.ts +++ /dev/null @@ -1,156 +0,0 @@ -process.env.STORAGE_TYPE = 'mongo' -process.env.OAUTH2_SERVER_CLIENTS = JSON.stringify([{ - id: 'native-app-client', - name: 'Native App Client', - redirectUris: ['native-app://auth-callback'] -}]) - -import { strict as assert } from 'node:assert' -import { it, describe, before, beforeEach, after } from 'node:test' -import { axios, clean, startApiServer, stopApiServer, createUser } from './utils/index.ts' - -describe('OAuth2 Authorization Code Flow', () => { - before(startApiServer) - beforeEach(async () => await clean()) - after(stopApiServer) - - it('should implement Authorization Code flow for native apps', async () => { - // 1. Create a user - const { ax, user } = await createUser('native-test@test.com') - - // 2. Call /authorize endpoint (simulating browser request with active session) - // The user is already logged in via `ax` (which has cookies) - const authorizeUrl = '/api/auth/authorize?response_type=code&client_id=native-app-client&redirect_uri=native-app://auth-callback' - - const authorizeRes = await ax.get(authorizeUrl, { - maxRedirects: 0, - validateStatus: (status) => status === 302 - }) - - // 3. Verify redirect to custom scheme with code - const redirectUrl = new URL(authorizeRes.headers.location) - assert.equal(redirectUrl.protocol, 'native-app:') - assert.equal(redirectUrl.host, 'auth-callback') - - const code = redirectUrl.searchParams.get('code') - assert.ok(code, 'Authorization code should be present') - - // 4. Exchange code for token (simulating native app request) - // This request comes from the app, so it doesn't have the browser session cookies - const appAx = await axios() - - const tokenRes = await appAx.post('/api/auth/token', { - grant_type: 'authorization_code', - code, - client_id: 'native-app-client' - }) - - assert.equal(tokenRes.status, 200) - assert.ok(tokenRes.data.access_token, 'Access token should be present') - assert.ok(tokenRes.data.id_token_ex, 'Exchange token should be present') - - // 5. Verify the access token is valid and belongs to the user - // The access_token is a JWT in this implementation (parts 0 and 1 + sign part 2) - const tokenParts = tokenRes.data.access_token.split('.') - assert.equal(tokenParts.length, 3) - - const payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()) - assert.equal(payload.id, user.id) - assert.equal(payload.email, 'native-test@test.com') - - // 6. Security Check: Verify code replay prevention - // Try to use the same code again -> should fail - try { - await appAx.post('/api/auth/token', { - grant_type: 'authorization_code', - code, - client_id: 'native-app-client' - }) - throw new Error('REPLAY_SUCCESS') // Throw a specific error if it succeeds - } catch (err: any) { - if (err.message === 'REPLAY_SUCCESS') { - assert.fail('Security Vulnerability: Authorization code was successfully reused!') - } - const status = err.response?.status || err.status - assert.equal(status, 400, `Expected 400 but got ${status} (Error: ${err.message})`) - - const data = err.response?.data || err.data || err.message - assert.match(String(data), /Invalid or expired code/) - } - }) - - it('should redirect to site-specific login page when accessing via site URL', async () => { - const config = (await import('../api/src/config.ts')).default - - // Create a site - const { ax: adminAx } = await createUser('admin@test.com', true) - const org = (await adminAx.post('/api/organizations', { name: 'Site Org' })).data - - // Determine port from adminAx defaults or process - const port = new URL(adminAx.defaults.baseURL || '').port - const siteHost = `127.0.0.1:${port}` - - const anonymousAx = await axios() - await anonymousAx.post('/api/sites', - { _id: 'test-site', owner: { type: 'organization', id: org.id, name: org.name }, host: siteHost, theme: { primaryColor: '#000000' } }, - { params: { key: config.secretKeys.sites } } - ) - - // Access authorize endpoint via site host - const siteAx = await axios({ baseURL: `http://${siteHost}/simple-directory` }) - - const authorizeUrl = '/api/auth/authorize?response_type=code&client_id=native-app-client&redirect_uri=native-app://auth-callback' - - const res = await siteAx.get(authorizeUrl, { - maxRedirects: 0, - validateStatus: (status) => status === 302 - }) - - const redirectUrl = new URL(res.headers.location) - - // Verify that the login redirect targets the site host (127.0.0.1) and not the default (localhost) - assert.equal(redirectUrl.hostname, '127.0.0.1') - assert.ok(redirectUrl.pathname.includes('/login')) - }) - - it('should preserve state parameter for CSRF protection', async () => { - const { ax } = await createUser('native-test-state@test.com') - const state = 'random-csrf-token-123' - - const authorizeUrl = `/api/auth/authorize?response_type=code&client_id=native-app-client&redirect_uri=native-app://auth-callback&state=${state}` - - const authorizeRes = await ax.get(authorizeUrl, { - maxRedirects: 0, - validateStatus: (status) => status === 302 - }) - - const redirectUrl = new URL(authorizeRes.headers.location) - assert.equal(redirectUrl.searchParams.get('state'), state) - }) - - it('should reject invalid client_id', async () => { - const { ax } = await createUser('native-test-2@test.com') - - const authorizeUrl = '/api/auth/authorize?response_type=code&client_id=invalid-client&redirect_uri=native-app://auth-callback' - - const authorizeRes = await ax.get(authorizeUrl, { - maxRedirects: 0, - validateStatus: (status) => status === 400 - }) - - assert.match(authorizeRes.data, /Unknown client_id/) - }) - - it('should reject invalid redirect_uri', async () => { - const { ax } = await createUser('native-test-3@test.com') - - const authorizeUrl = '/api/auth/authorize?response_type=code&client_id=native-app-client&redirect_uri=http://evil.com/callback' - - const authorizeRes = await ax.get(authorizeUrl, { - maxRedirects: 0, - validateStatus: (status) => status === 400 - }) - - assert.match(authorizeRes.data, /Invalid redirect_uri/) - }) -}) From 0b442d101bd0c56af5ad08a8574268fbd8f590fe Mon Sep 17 00:00:00 2001 From: kernoeb Date: Wed, 14 Jan 2026 00:41:19 +0100 Subject: [PATCH 5/8] refactor: remove oauth2 comments and ts-ignore --- api/src/auth/router.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/api/src/auth/router.ts b/api/src/auth/router.ts index c6a02326..a890e387 100644 --- a/api/src/auth/router.ts +++ b/api/src/auth/router.ts @@ -845,21 +845,19 @@ router.post('/saml2-logout', (req, res) => { }); }) */ -// OAuth2-like authorize endpoint for external apps +// Authorize endpoint for external apps router.get('/apps/authorize', async (req, res) => { const { client_id: clientId, redirect_uri: redirectUri, state } = req.query if (!redirectUri || typeof redirectUri !== 'string') return res.status(400).send('Missing redirect_uri') if (!clientId || typeof clientId !== 'string') return res.status(400).send('Missing client_id') const site = await reqSite(req) - // @ts-ignore let client = (site?.applications || []).find(c => c.id === clientId) if (!client && !site) { client = (config.applications || []).find(c => c.id === clientId) } if (!client) return res.status(400).send('Unknown client_id') - // @ts-ignore if (!client.redirectUris.some(uri => redirectUri.startsWith(uri))) { return res.status(400).send('Invalid redirect_uri') } @@ -894,7 +892,7 @@ router.get('/apps/authorize', async (req, res) => { res.redirect(callbackUrl.href) }) -// OAuth2-like login endpoint for external apps (exchanges code for session cookies) +// Login endpoint for external apps (exchanges code for session cookies) router.post('/apps/login', async (req, res) => { const { code } = req.body if (!code) return res.status(400).send('Missing code') From 901d9439b9936528a338ae4a8284e01430479532 Mon Sep 17 00:00:00 2001 From: kernoeb Date: Wed, 14 Jan 2026 09:37:37 +0100 Subject: [PATCH 6/8] feat: add user confirmation step before authorizing external apps - GET /api/auth/apps/authorize now redirects to login UI with step=authorizeApp - Added POST /api/auth/apps/authorize endpoint that generates the auth code after user confirms - Added authorizeApp step in login.vue with confirmation message and buttons - User must click 'Authorize' to generate code, can click 'Cancel' to deny access - Added i18n translations for authorization UI (fr/en) --- api/i18n/en.js | 5 +- api/i18n/fr.js | 5 +- api/src/auth/router.ts | 46 ++++++++++------ test-it/external-apps-authorization.ts | 72 +++++++++++++++++++++---- ui/src/pages/login.vue | 73 +++++++++++++++++++++++++- 5 files changed, 173 insertions(+), 28 deletions(-) diff --git a/api/i18n/en.js b/api/i18n/en.js index 36811308..73c13dba 100644 --- a/api/i18n/en.js +++ b/api/i18n/en.js @@ -225,7 +225,10 @@ Can be 'anonymous', 'authenticated' or 'admin'.`, cancelDeletion: 'Cancel the deletion of the user', siteLogo: 'Site logo', partnerInvitation: 'Partner invitation', - changeHost: 'Account associated with back-office' + changeHost: 'Account associated with back-office', + authorizeApp: 'Authorize application', + authorizeAppMsg: 'The application {appName} wants to access your account. Do you want to authorize it?', + authorizeAppConfirm: 'Authorize' }, organization: { addMember: 'Invite a user to join this organization', diff --git a/api/i18n/fr.js b/api/i18n/fr.js index d2632047..ce8e83db 100644 --- a/api/i18n/fr.js +++ b/api/i18n/fr.js @@ -226,7 +226,10 @@ Peut valoir 'anonymous', 'authenticated' ou 'admin'.`, cancelDeletion: 'Annuler la suppression de l\'utilisateur', siteLogo: 'Logo du site', partnerInvitation: 'Invitation partenaire', - changeHost: 'Compte associé au back-office' + changeHost: 'Compte associé au back-office', + authorizeApp: 'Autoriser l\'application', + authorizeAppMsg: 'L\'application {appName} souhaite accéder à votre compte. Voulez-vous l\'autoriser ?', + authorizeAppConfirm: 'Autoriser' }, organization: { addMember: 'Inviter un utilisateur à rejoindre l\'organisation', diff --git a/api/src/auth/router.ts b/api/src/auth/router.ts index a890e387..1ba11578 100644 --- a/api/src/auth/router.ts +++ b/api/src/auth/router.ts @@ -845,7 +845,7 @@ router.post('/saml2-logout', (req, res) => { }); }) */ -// Authorize endpoint for external apps +// Authorize endpoint for external apps - redirects to UI for user confirmation router.get('/apps/authorize', async (req, res) => { const { client_id: clientId, redirect_uri: redirectUri, state } = req.query if (!redirectUri || typeof redirectUri !== 'string') return res.status(400).send('Missing redirect_uri') @@ -862,14 +862,37 @@ router.get('/apps/authorize', async (req, res) => { return res.status(400).send('Invalid redirect_uri') } - const user = reqUser(req) - if (!user) { - const loginUrl = new URL(reqSiteUrl(req) + '/simple-directory/login') - const authorizeUrl = new URL(req.originalUrl, reqSiteUrl(req)) - loginUrl.searchParams.set('redirect', authorizeUrl.href) - return res.redirect(loginUrl.href) + // Redirect to UI for user confirmation (login first if not authenticated) + const loginUrl = new URL(reqSiteUrl(req) + '/simple-directory/login') + loginUrl.searchParams.set('step', 'authorizeApp') + loginUrl.searchParams.set('client_id', clientId) + loginUrl.searchParams.set('client_name', client.name) + loginUrl.searchParams.set('redirect_uri', redirectUri) + if (state && typeof state === 'string') loginUrl.searchParams.set('state', state) + + res.redirect(loginUrl.href) +}) + +// Authorize confirmation endpoint - generates code after user confirms +router.post('/apps/authorize', async (req, res) => { + const { client_id: clientId, redirect_uri: redirectUri, state } = req.body + if (!redirectUri || typeof redirectUri !== 'string') return res.status(400).send('Missing redirect_uri') + if (!clientId || typeof clientId !== 'string') return res.status(400).send('Missing client_id') + + const site = await reqSite(req) + let client = (site?.applications || []).find(c => c.id === clientId) + if (!client && !site) { + client = (config.applications || []).find(c => c.id === clientId) + } + if (!client) return res.status(400).send('Unknown client_id') + + if (!client.redirectUris.some(uri => redirectUri.startsWith(uri))) { + return res.status(400).send('Invalid redirect_uri') } + const user = reqUser(req) + if (!user) return res.status(401).send('Not authenticated') + const codePayload = { userId: user.id, clientId: client.id, @@ -882,14 +905,7 @@ router.get('/apps/authorize', async (req, res) => { callbackUrl.searchParams.set('code', code) if (state && typeof state === 'string') callbackUrl.searchParams.set('state', state) - // preserve other query params if needed? (e.g. for desktop app context) - for (const [key, value] of Object.entries(req.query)) { - if (!['code', 'state', 'redirect_uri', 'response_type', 'client_id'].includes(key) && typeof value === 'string') { - callbackUrl.searchParams.set(key, value) - } - } - - res.redirect(callbackUrl.href) + res.send({ redirectUrl: callbackUrl.href }) }) // Login endpoint for external apps (exchanges code for session cookies) diff --git a/test-it/external-apps-authorization.ts b/test-it/external-apps-authorization.ts index 0a82bc16..5db4afa0 100644 --- a/test-it/external-apps-authorization.ts +++ b/test-it/external-apps-authorization.ts @@ -44,24 +44,41 @@ describe('External Apps Authorization Flow', () => { // 2. Create a user on the site const { ax: userSiteAx } = await createUser('native-test@test.com', false, 'TestPasswd01', `http://${siteHost}/simple-directory`) - // 3. Call /authorize endpoint (simulating browser request with active session) - const authorizeUrl = '/api/auth/apps/authorize?client_id=native-app-client&redirect_uri=native-app://auth-callback' + // 3. Call GET /authorize endpoint - should redirect to login UI + const authorizeGetUrl = '/api/auth/apps/authorize?client_id=native-app-client&redirect_uri=native-app://auth-callback' - const authorizeRes = await userSiteAx.get(authorizeUrl, { + const authorizeGetRes = await userSiteAx.get(authorizeGetUrl, { maxRedirects: 0, validateStatus: (status) => status === 302 }) - console.log('Redirect location:', authorizeRes.headers.location) + console.log('GET Redirect location:', authorizeGetRes.headers.location) + + // Verify redirect to login UI with authorizeApp step + const loginRedirectUrl = new URL(authorizeGetRes.headers.location) + assert.equal(loginRedirectUrl.pathname, '/simple-directory/login') + assert.equal(loginRedirectUrl.searchParams.get('step'), 'authorizeApp') + assert.equal(loginRedirectUrl.searchParams.get('client_id'), 'native-app-client') + assert.equal(loginRedirectUrl.searchParams.get('client_name'), 'Native App') + assert.equal(loginRedirectUrl.searchParams.get('redirect_uri'), 'native-app://auth-callback') + + // 4. Call POST /authorize to confirm authorization (simulating user clicking "Authorize") + const authorizePostRes = await userSiteAx.post('/api/auth/apps/authorize', { + client_id: 'native-app-client', + redirect_uri: 'native-app://auth-callback' + }) + + assert.equal(authorizePostRes.status, 200) + assert.ok(authorizePostRes.data.redirectUrl, 'Should return redirect URL') - // 4. Verify redirect to custom scheme with code - const redirectUrl = new URL(authorizeRes.headers.location) + // 5. Verify redirect URL contains code + const redirectUrl = new URL(authorizePostRes.data.redirectUrl) assert.equal(redirectUrl.protocol, 'native-app:') assert.equal(redirectUrl.host, 'auth-callback') const code = redirectUrl.searchParams.get('code') assert.ok(code, 'Authorization code should be present') - // 5. Exchange code for session (simulating native app request) + // 6. Exchange code for session (simulating native app request) const appAx = await axios({ baseURL: `http://${siteHost}/simple-directory` }) const loginRes2 = await appAx.post('/api/auth/apps/login', { @@ -77,7 +94,7 @@ describe('External Apps Authorization Flow', () => { assert.ok(appCookies, 'Session cookies should be set') assert.ok(appCookies.some(c => c.startsWith('id_token')), 'id_token should be present') - // 6. Verify session works + // 7. Verify session works const appSessionAx = await axios({ baseURL: `http://${siteHost}/simple-directory`, headers: { Cookie: appCookies } @@ -86,15 +103,50 @@ describe('External Apps Authorization Flow', () => { assert.equal(meRes.status, 200) assert.equal(meRes.data.email, 'native-test@test.com') - // 7. Replay Check: Code reuse (stateless JWT) + // 8. Replay Check: Code reuse (stateless JWT) // Since it's stateless, it CAN be reused within validity period. - // The previous test asserted failure. Here we acknowledge it succeeds (logging in again). const replayRes = await appAx.post('/api/auth/apps/login', { code }) assert.equal(replayRes.status, 200) }) + it('should reject POST authorize without authentication', async () => { + const config = (await import('../api/src/config.ts')).default + const { ax: adminAx } = await createUser('admin@test.com', true) + const org = (await adminAx.post('/api/organizations', { name: 'Site Org Auth' })).data + const port = new URL(adminAx.defaults.baseURL || '').port + const siteHost = `127.0.0.1:${port}` + + const anonymousAx = await axios() + await anonymousAx.post('/api/sites', + { + _id: 'test-site-auth', + owner: { type: 'organization', id: org.id, name: org.name }, + host: siteHost, + theme: { primaryColor: '#000000' }, + applications: [{ + id: 'native-app-client', + name: 'Native App', + redirectUris: ['native-app://auth-callback'] + }] + }, + { params: { key: config.secretKeys.sites } } + ) + + const siteAx = await axios({ baseURL: `http://${siteHost}/simple-directory` }) + + // POST without authentication should fail + const authorizeRes = await siteAx.post('/api/auth/apps/authorize', { + client_id: 'native-app-client', + redirect_uri: 'native-app://auth-callback' + }, { + validateStatus: (status) => status === 401 + }) + + assert.match(authorizeRes.data, /Not authenticated/) + }) + it('should reject invalid client_id', async () => { const config = (await import('../api/src/config.ts')).default const { ax: adminAx } = await createUser('admin@test.com', true) diff --git a/ui/src/pages/login.vue b/ui/src/pages/login.vue index 090049e1..1768b99d 100644 --- a/ui/src/pages/login.vue +++ b/ui/src/pages/login.vue @@ -724,6 +724,36 @@ + + +

+ {{ $t('pages.login.authorizeAppMsg', { appName: appClientName }) }} +

+ +
+ + + + {{ $t('common.confirmCancel') }} + + + + {{ $t('pages.login.authorizeAppConfirm') }} + + +
+ = { createOrga: t('common.createOrganization'), plannedDeletion: t('pages.login.plannedDeletion'), partnerInvitation: t('pages.login.partnerInvitation'), - changeHost: t('pages.login.changeHost') + changeHost: t('pages.login.changeHost'), + authorizeApp: t('pages.login.authorizeApp') } const createUserStep = () => { step.value = $uiConfig.tosUrl ? 'tos' : 'createUser' @@ -1114,6 +1145,46 @@ function clearError () { function goToRedirect () { window.location.href = redirect } + +// External app authorization +const appClientId = reactiveSearchParams.client_id +const appClientName = reactiveSearchParams.client_name +const appRedirectUri = reactiveSearchParams.redirect_uri +const appState = reactiveSearchParams.state + +// Handle authorizeApp step - redirect to login first if not authenticated +if (step.value === 'authorizeApp' && !user.value) { + const loginUrl = new URL(window.location.href) + loginUrl.searchParams.set('step', 'login') + const authorizeUrl = new URL(window.location.href) + authorizeUrl.searchParams.set('step', 'authorizeApp') + loginUrl.searchParams.set('redirect', authorizeUrl.href) + window.location.replace(loginUrl.href) +} + +const authorizeApp = useAsyncAction(async () => { + const res = await $fetch<{ redirectUrl: string }>('auth/apps/authorize', { + method: 'POST', + body: { + client_id: appClientId, + redirect_uri: appRedirectUri, + state: appState + } + }) + window.location.href = res.redirectUrl +}, { catch: 'all' }) + +function cancelAuthorizeApp () { + // Redirect back to app with error + if (appRedirectUri) { + const callbackUrl = new URL(appRedirectUri) + callbackUrl.searchParams.set('error', 'access_denied') + if (appState) callbackUrl.searchParams.set('state', appState) + window.location.href = callbackUrl.href + } else { + step.value = 'login' + } +}