diff --git a/api/config/custom-environment-variables.cjs b/api/config/custom-environment-variables.cjs index ebf6cb05..37117d10 100644 --- a/api/config/custom-environment-variables.cjs +++ b/api/config/custom-environment-variables.cjs @@ -289,6 +289,7 @@ module.exports = { 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 7ebc5b8b..cc4ade79 100644 --- a/api/config/default.cjs +++ b/api/config/default.cjs @@ -370,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 7c9d5646..988c8a10 100644 --- a/api/config/type/schema.json +++ b/api/config/type/schema.json @@ -431,6 +431,13 @@ } } }, + "applications": { + "type": "array", + "items": { + "$ref": "#/$defs/application" + }, + "default": [] + }, "webhooks": { "type": "object", "required": [ @@ -620,6 +627,18 @@ "users": { "type": "string" }, "organizations": { "type": "string" } } + }, + "application": { + "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/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/i18n/en.js b/api/i18n/en.js index 36811308..19efa8a1 100644 --- a/api/i18n/en.js +++ b/api/i18n/en.js @@ -225,7 +225,12 @@ 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', + appRedirected: 'Redirected', + appRedirectedMsg: 'You can close this tab.' }, organization: { addMember: 'Invite a user to join this organization', diff --git a/api/i18n/fr.js b/api/i18n/fr.js index d2632047..6394ca2e 100644 --- a/api/i18n/fr.js +++ b/api/i18n/fr.js @@ -226,7 +226,12 @@ 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', + appRedirected: 'Redirection effectuée', + appRedirectedMsg: 'Vous pouvez fermer cet onglet.' }, organization: { addMember: 'Inviter un utilisateur à rejoindre l\'organisation', diff --git a/api/src/auth/router.ts b/api/src/auth/router.ts index 8789fb5b..1ba11578 100644 --- a/api/src/auth/router.ts +++ b/api/src/auth/router.ts @@ -844,3 +844,96 @@ router.post('/saml2-logout', (req, res) => { res.redirect(logout_url);async }); }) */ + +// 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') + 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') + } + + // 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, + redirectUri, + 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) + + res.send({ redirectUrl: callbackUrl.href }) +}) + +// 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') + + 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 = decoded.userId === '_superadmin' ? superadmin : await storage.getUser(decoded.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) + + await confirmLog(storage, user, serverSession) + await setSessionCookies(req, res, payload, serverSession.id, getDefaultUserOrg(user, site)) + + res.send(user) +}) diff --git a/api/types/index.ts b/api/types/index.ts index fba55c01..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 }, 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/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" }, diff --git a/test-it/external-apps-authorization.ts b/test-it/external-apps-authorization.ts new file mode 100644 index 00000000..5db4afa0 --- /dev/null +++ b/test-it/external-apps-authorization.ts @@ -0,0 +1,180 @@ +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 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 authorizeGetRes = await userSiteAx.get(authorizeGetUrl, { + maxRedirects: 0, + validateStatus: (status) => status === 302 + }) + 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') + + // 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') + + // 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', { + 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') + + // 7. 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') + + // 8. Replay Check: Code reuse (stateless JWT) + // Since it's stateless, it CAN be reused within validity period. + 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) + 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/ui/src/pages/login.vue b/ui/src/pages/login.vue index 090049e1..8b0ad7a5 100644 --- a/ui/src/pages/login.vue +++ b/ui/src/pages/login.vue @@ -19,7 +19,7 @@ cols="12" style="max-width: 500px;" > - + + + +

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

+ +
+ + + + {{ $t('common.confirmCancel') }} + + + + {{ $t('pages.login.authorizeAppConfirm') }} + + +
+ + + + + +

{{ $t('pages.login.appRedirectedMsg') }}

+
+

= { 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'), + appRedirected: t('pages.login.appRedirected') } const createUserStep = () => { step.value = $uiConfig.tosUrl ? 'tos' : 'createUser' @@ -1114,6 +1158,56 @@ 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 + } + }) + // Redirect to app and show success message + // Note: we don't try to close the window automatically because: + // 1. For custom protocol URLs (my-app://), there's no way to detect if user clicked "Open" + // 2. window.close() only works for windows opened via window.open() + window.location.href = res.redirectUrl + setTimeout(() => { + step.value = 'appRedirected' + }, 100) +}, { 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 + setTimeout(() => { + step.value = 'appRedirected' + }, 100) + } else { + step.value = 'login' + } +}