diff --git a/docs/5-add-identity.md b/docs/5-add-identity.md index bf3d33206..095021efe 100644 --- a/docs/5-add-identity.md +++ b/docs/5-add-identity.md @@ -83,6 +83,7 @@ We'll create two GitHub apps: one for testing locally and another for production AZURE_AD_CLIENT_ID= AZURE_AD_CLIENT_SECRET= AZURE_AD_TENANT_ID= +AZURE_AD_ALLOWED_PRINCIPALS= ``` ## Configure an admin user diff --git a/docs/7-environment-variables.md b/docs/7-environment-variables.md index 0accbe5b8..7da761892 100644 --- a/docs/7-environment-variables.md +++ b/docs/7-environment-variables.md @@ -17,6 +17,7 @@ Below are the required environment variables, to be added to the Azure Portal or | `AZURE_AD_CLIENT_ID` | | The client id specific to the application | | `AZURE_AD_CLIENT_SECRET` | | The client secret specific to the application | | `AZURE_AD_TENANT_ID` | | The organisation Tenant ID | +| `AZURE_AD_ALLOWED_PRINCIPALS` | | Comma separated list of Object IDs for users/groups that are allowed to log in. **All authenticated users are allowed by default** | | `ADMIN_EMAIL_ADDRESS` | | Comma separated list of email addresses of the admin users ID | | **Azure Cognitive Search is optional. This is only required for chat over file feature.** | | `AZURE_SEARCH_API_KEY` | | API Key of Azure Cognitive search | diff --git a/src/features/auth/auth-api.ts b/src/features/auth/auth-api.ts index 73fbc0cb6..e1f73b31c 100644 --- a/src/features/auth/auth-api.ts +++ b/src/features/auth/auth-api.ts @@ -8,7 +8,8 @@ import { hashValue } from "./helpers"; const configureIdentityProvider = () => { const providers: Array = []; - const adminEmails = process.env.ADMIN_EMAIL_ADDRESS?.split(",").map(email => email.toLowerCase().trim()); + const adminEmails = process.env.ADMIN_EMAIL_ADDRESS?.split(",").map(email => email.toLowerCase().trim()).filter(email => email); + const azureAdAllowedPrincipals = process.env.AZURE_AD_ALLOWED_PRINCIPALS?.split(",").map(oid => oid.toLowerCase().trim()).filter(oid => oid); if (process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET) { providers.push( @@ -18,7 +19,8 @@ const configureIdentityProvider = () => { async profile(profile) { const newProfile = { ...profile, - isAdmin: adminEmails?.includes(profile.email.toLowerCase()) + isAdmin: adminEmails?.includes(profile.email.toLowerCase()), + isAllowed: true } return newProfile; } @@ -36,13 +38,60 @@ const configureIdentityProvider = () => { clientId: process.env.AZURE_AD_CLIENT_ID!, clientSecret: process.env.AZURE_AD_CLIENT_SECRET!, tenantId: process.env.AZURE_AD_TENANT_ID!, - async profile(profile) { - + authorization: { + params: { + // Add User.Read to reach the /me endpoint of Microsoft Graph + scope: 'email openid profile User.Read' + } + }, + async profile(profile, tokens) { + let isAllowed = true + if (Array.isArray(azureAdAllowedPrincipals) && azureAdAllowedPrincipals.length > 0) { + try { + isAllowed = false + // POST https://graph.microsoft.com/v1.0/me/getMemberObjects + // It returns all IDs of principal objects which "me" is a member of (transitive) + // https://learn.microsoft.com/en-us/graph/api/directoryobject-getmemberobjects?view=graph-rest-1.0&tabs=http + const response = await fetch( + 'https://graph.microsoft.com/v1.0/me/getMemberObjects', + { + method: 'POST', + headers: { + Authorization: `Bearer ${tokens.access_token}`, + 'Content-Type': 'application/json' + }, + body: '{"securityEnabledOnly":true}' + } + ) + if (response.ok) { + const body = await response.json() as { value?: string[] } + const oids = body.value ?? [] + if (profile.oid) { + // Append the object ID of user principal "me" + oids.push(profile.oid) + } + for (const principal of azureAdAllowedPrincipals) { + if (oids.includes(principal)) { + isAllowed = true + break + } + } + } + else { + const body = await response.text() + throw new Error(`Bad response from POST /me/getMemberObjects: ${response.status} ${response.statusText}: ${body}`) + } + } + catch (e) { + console.log(e) + } + } const newProfile = { ...profile, // throws error without this - unsure of the root cause (https://stackoverflow.com/questions/76244244/profile-id-is-missing-in-google-oauth-profile-response-nextauth) id: profile.sub, - isAdmin: adminEmails?.includes(profile.email.toLowerCase()) || adminEmails?.includes(profile.preferred_username.toLowerCase()) + isAdmin: adminEmails?.includes(profile.email.toLowerCase()) || adminEmails?.includes(profile.preferred_username.toLowerCase()), + isAllowed } return newProfile; } @@ -89,15 +138,18 @@ export const options: NextAuthOptions = { secret: process.env.NEXTAUTH_SECRET, providers: [...configureIdentityProvider()], callbacks: { - async jwt({token, user, account, profile, isNewUser, session}) { + async jwt({ token, user, account, profile, isNewUser, session }) { if (user?.isAdmin) { - token.isAdmin = user.isAdmin + token.isAdmin = user.isAdmin } return token }, - async session({session, token, user }) { + async session({ session, token, user }) { session.user.isAdmin = token.isAdmin as string return session + }, + async signIn({ user }) { + return user.isAllowed } }, session: { diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 1e44d8162..e970276be 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -12,6 +12,7 @@ declare module "next-auth" { interface User { isAdmin: string + isAllowed: boolean } }