Skip to content
Open
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
2 changes: 1 addition & 1 deletion api/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const flatOpts = { delimiter: '_' }
// {fr: {msg1: 'libellé 1'}, en: {msg1: 'label 1'}}
const _messages: any = {}
for (const l of config.i18n.locales) {
_messages[l] = (await import ('./' + l + '.js')).default
_messages[l] = (await import('./' + l + '.js')).default
}
export const flatMessages = flatten(_messages, flatOpts) as Record<string, string>

Expand Down
11 changes: 6 additions & 5 deletions api/src/auth/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ router.post('/keepalive', async (req, res, next) => {
await logout(req, res)
return res.status(401).send('Fournisseur d\'identité principal inconnu')
}
const oauthToken = (await readOAuthToken(user, provider))
const oauthToken = (await readOAuthToken(user, provider, site?._id))

if (!oauthToken) {
await logout(req, res)
Expand All @@ -501,7 +501,7 @@ router.post('/keepalive', async (req, res, next) => {
const userInfo = await provider.userInfo(newToken.access_token, newToken.id_token)
const memberInfos = await authProviderMemberInfo(await reqSite(req), provider, userInfo)
user = await patchCoreAuthUser(provider, user, userInfo, memberInfos)
await writeOAuthToken(user, provider, newToken, offlineRefreshToken)
await writeOAuthToken(user, provider, newToken, offlineRefreshToken, undefined, site?._id)
eventsLog.info('sd.auth.keepalive.oauth-refresh-ok', `a user refreshed their info from their core identity provider ${provider.id}`, { req })
}
} catch (err: any) {
Expand Down Expand Up @@ -719,7 +719,8 @@ const oauthCallback: RequestHandler = async (req, res, next) => {
try {
const [callbackUrl, user] = await authProviderLoginCallback(req, invitToken, authInfo, logContext, provider, redirect, org, dep, adminMode)
if (provider.coreIdProvider) {
await writeOAuthToken(user, provider, token, offlineRefreshToken)
const callbackSite = await reqSite(req)
await writeOAuthToken(user, provider, token, offlineRefreshToken, undefined, callbackSite?._id)
}
res.redirect(callbackUrl)
} catch (err : any) {
Expand Down Expand Up @@ -868,7 +869,7 @@ router.get('/apps/authorize', async (req, res) => {

const site = await reqSite(req)
let client = (site?.applications || []).find(c => c.id === clientId)
if (!client && !site) {
if (!client) {
client = (config.applications || []).find(c => c.id === clientId)
}
if (!client) return res.status(400).send('Unknown client_id')
Expand Down Expand Up @@ -896,7 +897,7 @@ router.post('/apps/authorize', async (req, res) => {

const site = await reqSite(req)
let client = (site?.applications || []).find(c => c.id === clientId)
if (!client && !site) {
if (!client) {
client = (config.applications || []).find(c => c.id === clientId)
}
if (!client) return res.status(400).send('Unknown client_id')
Expand Down
2 changes: 1 addition & 1 deletion api/src/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export class SdMongo {
'sites-owner': { 'owner.type': 1, 'owner.id': 1, 'owner.department': 1 }
},
'oauth-tokens': {
'oauth-tokens-key': [{ 'user.id': 1, 'provider.id': 1 }, { unique: true }],
'oauth-tokens-key': [{ 'user.id': 1, 'provider.id': 1, site: 1 }, { unique: true }],
'oauth-tokens-provider': { 'provider.id': 1 },
'oauth-tokens-offline': { offlineRefreshToken: 1 },
'oauth-tokens-sid': { 'token.session_state': 1 }
Expand Down
30 changes: 24 additions & 6 deletions api/src/oauth-tokens/service.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,41 @@
import type { User, OAuthToken } from '#types'
import mongo from '#mongo'

export async function writeOAuthToken (user: User, provider: any, token: any, offlineRefreshToken: boolean, loggedOut?: Date) {
export async function writeOAuthToken (user: User, provider: any, token: any, offlineRefreshToken: boolean, loggedOut?: Date, site?: string | null) {
const siteValue = site ?? null
const tokenInfo: OAuthToken = {
user: { id: user.id, email: user.email, name: user.name },
provider: { id: provider.id, type: provider.type, title: provider.title },
site: siteValue,
token
}
if (offlineRefreshToken) tokenInfo.offlineRefreshToken = true
if (loggedOut) tokenInfo.loggedOut = loggedOut
await mongo.oauthTokens
.replaceOne({ 'user.id': user.id, 'provider.id': provider.id }, tokenInfo, { upsert: true })
.replaceOne({ 'user.id': user.id, 'provider.id': provider.id, site: siteValue }, tokenInfo, { upsert: true })
// lazy migration: clean up legacy token without site
if (siteValue !== null) {
await mongo.oauthTokens.deleteOne({ 'user.id': user.id, 'provider.id': provider.id, site: null })
}
}

export async function readOAuthToken (user: User, provider: any) {
return mongo.oauthTokens.findOne({ 'user.id': user.id, 'provider.id': provider.id })
export async function readOAuthToken (user: User, provider: any, site?: string | null) {
const siteValue = site ?? null
const token = await mongo.oauthTokens.findOne({ 'user.id': user.id, 'provider.id': provider.id, site: siteValue })
// backward compat: fall back to legacy token (no site) if site-specific token not found
if (!token && siteValue !== null) {
return mongo.oauthTokens.findOne({ 'user.id': user.id, 'provider.id': provider.id, site: null })
}
return token
}

export async function deleteOAuthToken (user: User, provider: any) {
await mongo.oauthTokens.deleteOne({ 'user.id': user.id, 'provider.id': provider.id })
export async function deleteOAuthToken (user: User, provider: any, site?: string | null) {
const siteValue = site ?? null
await mongo.oauthTokens.deleteOne({ 'user.id': user.id, 'provider.id': provider.id, site: siteValue })
// also clean up legacy token without site
if (siteValue !== null) {
await mongo.oauthTokens.deleteOne({ 'user.id': user.id, 'provider.id': provider.id, site: null })
}
}

export async function readOAuthTokens () {
Expand All @@ -28,6 +45,7 @@ export async function readOAuthTokens () {
'token.session_state': 1,
offlineRefreshToken: 1,
provider: 1,
site: 1,
loggedOut: 1
}).toArray()
return {
Expand Down
7 changes: 5 additions & 2 deletions api/src/oauth/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,16 @@ export const getOidcProviderId = (url: string) => {

export async function completeOidcProvider (p: OpenIDConnect): Promise<OAuthProvider> {
const id = getOidcProviderId(p.discovery)
let discoveryContent = (await mongo.oidcDiscovery.findOne({ _id: id }))?.content
// use full discovery URL as cache key to avoid collision between providers
// on the same host but different paths (e.g. Azure AD multi-tenant)
const cacheKey = slug(p.discovery, { lower: true, strict: true })
let discoveryContent = (await mongo.oidcDiscovery.findOne({ _id: cacheKey }))?.content
if (discoveryContent) {
debug(`Read pre-fetched OIDC discovery info from db for provider ${id}`, discoveryContent)
} else {
discoveryContent = (await axios.get(p.discovery)).data
debug(`Fetched OIDC discovery info from ${p.discovery}`, discoveryContent)
await mongo.oidcDiscovery.insertOne({ _id: id, content: discoveryContent })
await mongo.oidcDiscovery.insertOne({ _id: cacheKey, content: discoveryContent })
}
const tokenURL = new URL(discoveryContent.token_endpoint)
const authURL = new URL(discoveryContent.authorization_endpoint)
Expand Down
4 changes: 2 additions & 2 deletions api/src/users/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,11 @@ const task = async () => {
const userInfo = await provider.userInfo(newToken.access_token, newToken.id_token)
const memberInfos = await authProviderMemberInfo(undefined, provider, userInfo)
await patchCoreAuthUser(provider, user, userInfo, memberInfos)
await writeOAuthToken(user, provider, newToken, offlineRefreshToken, token.loggedOut)
await writeOAuthToken(user, provider, newToken, offlineRefreshToken, token.loggedOut, token.site)
eventsLog.info('sd.cleanup-cron.offline-token.refresh-ok', `a user refreshed their info from their core identity provider ${provider.id}`, { user })
} catch (err: any) {
if (err?.data?.payload?.error === 'invalid_grant') {
await deleteOAuthToken(user, provider)
await deleteOAuthToken(user, provider, token.site)
eventsLog.warn('sd.cleanup-cron.offline-token.delete', `deleted invalid offline token for user ${user.id} and provider ${provider.id}`, { user })
await planDeletion(user)
} else {
Expand Down
1 change: 1 addition & 0 deletions api/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type OAuthToken = {
token: any,
provider: { type: string, id: string, title: string },
user: { id: string, name: string, email: string },
site?: string | null,
offlineRefreshToken?: boolean,
loggedOut?: Date
}
Expand Down
1 change: 0 additions & 1 deletion api/types/site/schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,6 @@ export default {
id: {
type: 'string',
title: 'Client ID',
readOnly: true,
layout: { cols: 12 }
},
name: {
Expand Down
110 changes: 110 additions & 0 deletions test-it/external-apps-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,116 @@ describe('External Apps Authorization Flow', () => {
assert.match(authorizeRes.data, /Not authenticated/)
})

it('should fall back to global applications when site has no applications', 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 Fallback' })).data
const port = new URL(adminAx.defaults.baseURL || '').port
const siteHost = `127.0.0.1:${port}`

const originalApps = config.applications
config.applications = [{
id: 'global-app',
name: 'Global App',
redirectUris: ['native-app://global-callback']
}]

try {
const anonymousAx = await axios()
await anonymousAx.post('/api/sites',
{
_id: 'test-site-fallback',
owner: { type: 'organization', id: org.id, name: org.name },
host: siteHost,
theme: { primaryColor: '#000000' }
},
{ params: { key: config.secretKeys.sites } }
)

await adminAx.patch('/api/sites/test-site-fallback', { authMode: 'onlyLocal' });
(await import('../api/src/sites/service.ts')).getSiteByHost.clear()

const siteAx = await axios({ baseURL: `http://${siteHost}/simple-directory` })

// Should find global-app via fallback
const authorizeRes = await siteAx.get(
'/api/auth/apps/authorize?client_id=global-app&redirect_uri=native-app://global-callback',
{ maxRedirects: 0, validateStatus: (status) => status === 302 }
)
const loginRedirectUrl = new URL(authorizeRes.headers.location)
assert.equal(loginRedirectUrl.searchParams.get('client_id'), 'global-app')
assert.equal(loginRedirectUrl.searchParams.get('client_name'), 'Global App')
} finally {
config.applications = originalApps
}
})

it('should merge global and site applications, site takes priority', 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 Merge' })).data
const port = new URL(adminAx.defaults.baseURL || '').port
const siteHost = `127.0.0.1:${port}`

const originalApps = config.applications
config.applications = [
{ id: 'global-only', name: 'Global Only', redirectUris: ['native-app://global-only'] },
{ id: 'shared-id', name: 'Global Shared', redirectUris: ['native-app://global-shared'] }
]

try {
const anonymousAx = await axios()
await anonymousAx.post('/api/sites',
{
_id: 'test-site-merge',
owner: { type: 'organization', id: org.id, name: org.name },
host: siteHost,
theme: { primaryColor: '#000000' },
applications: [
{ id: 'site-only', name: 'Site Only', redirectUris: ['native-app://site-only'] },
{ id: 'shared-id', name: 'Site Shared', redirectUris: ['native-app://site-shared'] }
]
},
{ params: { key: config.secretKeys.sites } }
)

await adminAx.patch('/api/sites/test-site-merge', { authMode: 'onlyLocal' });
(await import('../api/src/sites/service.ts')).getSiteByHost.clear()

const siteAx = await axios({ baseURL: `http://${siteHost}/simple-directory` })

// Site-only app should work
const res1 = await siteAx.get(
'/api/auth/apps/authorize?client_id=site-only&redirect_uri=native-app://site-only',
{ maxRedirects: 0, validateStatus: (status) => status === 302 }
)
assert.equal(new URL(res1.headers.location).searchParams.get('client_name'), 'Site Only')

// Global-only app should work via merge
const res2 = await siteAx.get(
'/api/auth/apps/authorize?client_id=global-only&redirect_uri=native-app://global-only',
{ maxRedirects: 0, validateStatus: (status) => status === 302 }
)
assert.equal(new URL(res2.headers.location).searchParams.get('client_name'), 'Global Only')

// Shared ID should use site version (site overrides global)
const res3 = await siteAx.get(
'/api/auth/apps/authorize?client_id=shared-id&redirect_uri=native-app://site-shared',
{ maxRedirects: 0, validateStatus: (status) => status === 302 }
)
assert.equal(new URL(res3.headers.location).searchParams.get('client_name'), 'Site Shared')

// Global redirect URI for shared-id should be rejected (site version takes priority)
const res4 = await siteAx.get(
'/api/auth/apps/authorize?client_id=shared-id&redirect_uri=native-app://global-shared',
{ maxRedirects: 0, validateStatus: (status) => status === 400 }
)
assert.match(res4.data, /Invalid redirect_uri/)
} finally {
config.applications = originalApps
}
})

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)
Expand Down
2 changes: 1 addition & 1 deletion ui/src/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

<v-divider class="my-3" />

<span class="subheading">{{ $t('root.description') }}</span>
<span class="text-subtitle-1">{{ $t('root.description') }}</span>
</v-col>
</v-row>
</v-container>
Expand Down
4 changes: 2 additions & 2 deletions ui/src/pages/invitation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@

<span
v-if="sameUser"
class="subheading"
class="text-subtitle-1"
v-html="$t('pages.invitation.msgSameUser', {profileUrl: $sdUrl + '/me'})"
/>
<span
v-else
class="subheading"
class="text-subtitle-1"
v-html="$t('pages.invitation.msgDifferentUser', {loginUrl: $sdUrl + '/login?email=' + encodeURIComponent(email)})"
/>
</v-col>
Expand Down
Loading