Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/config/custom-environment-variables.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ module.exports = {
sp: jsonEnv('SAML2_SP'),
providers: jsonEnv('SAML2_PROVIDERS')
},
applications: jsonEnv('APPLICATIONS'),
oidc: {
providers: jsonEnv('OIDC_PROVIDERS')
},
Expand Down
1 change: 1 addition & 0 deletions api/config/default.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ module.exports = {
// for identify provider https://samlify.js.org/#/idp-configuration
providers: []
},
applications: [],
oidc: {
providers: []
},
Expand Down
19 changes: 19 additions & 0 deletions api/config/type/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,13 @@
}
}
},
"applications": {
"type": "array",
"items": {
"$ref": "#/$defs/application"
},
"default": []
},
"webhooks": {
"type": "object",
"required": [
Expand Down Expand Up @@ -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" }
}
}
}
}
}
4 changes: 2 additions & 2 deletions api/doc/sites/patch-req-body/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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']
}
]
}
Expand Down
2 changes: 1 addition & 1 deletion api/doc/sites/post-req-body/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion api/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
7 changes: 6 additions & 1 deletion api/i18n/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
93 changes: 93 additions & 0 deletions api/src/auth/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
6 changes: 6 additions & 0 deletions api/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ export type UserWritable = Omit<User, 'created' | 'updated' | 'name' | 'sessions

export type Member = Pick<User, 'id' | 'name' | 'email' | 'emailConfirmed' | 'host' | 'plannedDeletion'> & Pick<FullOrganizationMembership, 'createdAt' | 'role' | 'department' | 'departmentName' | 'readOnly'>

export type Application = {
id: string
name: string
redirectUris: string[]
}

export type OAuthToken = {
token: any,
provider: { type: string, id: string, title: string },
Expand Down
40 changes: 40 additions & 0 deletions api/types/site/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading