Skip to content

Commit

Permalink
Covert Azure AD auth to dynamic configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
TheUnlocked committed Sep 22, 2023
1 parent 711b93f commit a1f06d7
Show file tree
Hide file tree
Showing 13 changed files with 311 additions and 128 deletions.
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,8 @@ Necode also has a required websocket server, which can be built with `wsserver:b

## First-Time Setup

Sign in using the MSAL login that you configured in `.env`.
If you are working in a development environment, you can also configure GitHub authentication,
as well as log in with any username using the password you defined with the `DEV_PASSWORD` environment variable.
If you are in production and using Azure Active Directory, see "Using Azure Active Directory" below.
If you are working in a development environment, you can log in with any username using the password you defined with the `DEV_PASSWORD` environment variable.

You will now need to make a manual database change. While you can do this through `psql` or pgAdmin,
it may be easier to use Prisma Studio:
Expand All @@ -121,6 +120,36 @@ Once you've chosen a display name, press "Create" and wait a few moments.
It may take some time to load the newly created classroom, especially in a development environment.
Eventually you should come to the manage classroom page. From here you can create activities and manage your classroom.

### Using Azure Active Directory

To set up azure active directory, you will first need to create an application.
This page has some instructions on getting this set up: https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-web-app-nodejs-msal-sign-in
The redirect URIs are:
```
{protocol}://{origin}/api/auth/signin/wpi
{protocol}://{origin}/api/auth/callback/wpi
```

To link this new application Necode, you will need to make some direct database changes.
The easiest way to do this is through Prisma Studio.

1. Navigate to the `packages/database` directory and run `pnpm studio`. Open the link it puts in the console.
2. Click "SystemConfiguration".
3. Add the following four records with the appropriate values:

```
| key | value |
|-------------------------------------------------------------------|
| auth.azure.loginName | Your organization's name (e.g. WPI) |
| auth.azure.clientId | <your app's client/application ID> |
| auth.azure.clientSecret | <your app's client secret> |
| auth.azure.tenantId | <your organization's tenant ID> |
```

4. Then make sure to save your changes.

You should now be able to log in using your Microsoft account.

## Contributing

### Changing the database
Expand Down
7 changes: 5 additions & 2 deletions apps/necode/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { NextApiRequest, NextApiResponse } from 'next';
import nextAuth from 'next-auth';
import { nextAuthOptions } from '~backend/nextAuth';
import { getNextAuthOptions } from '~backend/nextAuth';

export default nextAuth(nextAuthOptions);
export default async function auth(req: NextApiRequest, res: NextApiResponse) {
return await nextAuth(req, res, await getNextAuthOptions());
}
70 changes: 70 additions & 0 deletions apps/necode/pages/api/configuration/[key].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Status, endpoint } from "~backend/Endpoint";
import { configOptions } from "~backend/config";
import { makeConfigurationEntity } from "~api/entities/ConfigurationEntity";
import { hasScope } from "~backend/scopes";
import { $in } from "~utils/typeguards";
import { prisma } from "@necode-org/database";
import Joi from "joi";

const apiConfigurationOne = endpoint(makeConfigurationEntity, ['key'], {
type: 'entity',
GET: {
loginValidation: true,
handler: async ({ query: { key }, session }, ok, fail) => {
if (!await hasScope(session!.user.id, 'configuration:read', { key })) {
return fail(Status.FORBIDDEN);
}

if (!$in(key, configOptions)) {
return fail(Status.NOT_FOUND);
}

const configSettings = configOptions[key];

if (configSettings.writeOnly) {
return fail(Status.FORBIDDEN, `${key} is a write-only configuration option`);
}

const config = await prisma.systemConfiguration.upsert({
where: { key },
create: {
key,
value: configSettings.default,
},
update: {},
});

return ok(makeConfigurationEntity(config));
}
},
PUT: {
loginValidation: true,
schema: Joi.object({
value: Joi.string(),
}),
handler: async ({ query: { key }, session, body }, ok, fail) => {
if (!await hasScope(session!.user.id, 'configuration:write', { key })) {
return fail(Status.FORBIDDEN);
}

if (!$in(key, configOptions)) {
return fail(Status.NOT_FOUND);
}

const config = await prisma.systemConfiguration.upsert({
where: { key },
create: {
key,
value: body.value,
},
update: {
value: body.value,
},
});

return ok(makeConfigurationEntity(config));
}
},
});

export default apiConfigurationOne;
17 changes: 17 additions & 0 deletions packages/api/src/entities/ConfigurationEntity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { SystemConfiguration } from "~database";
import { Entity, EntityType } from "./Entity";

export type ConfigurationEntity
= Entity<EntityType.Configuration, {
value: string;
}>;

export function makeConfigurationEntity(configuration: SystemConfiguration): ConfigurationEntity {
return {
type: EntityType.Configuration,
id: configuration.key,
attributes: {
value: configuration.value,
}
};
}
1 change: 1 addition & 0 deletions packages/api/src/entities/Entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export enum EntityType {
ActivitySubmission = 'submission',
Plugin = 'plugin',
RtcPolicy = 'rtc-policy',
Configuration = 'configuration',
}

export type EntityId = string;
Expand Down
42 changes: 42 additions & 0 deletions packages/backend/src/auth/azure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { OAuthConfig, OAuthUserConfig } from "next-auth/providers";

interface AzureProfile {
businessPhones: string[],
displayName: string,
givenName: string,
jobTitle: string
mail: string,
mobilePhone: null,
officeLocation: null,
preferredLanguage: null,
surname: string,
userPrincipalName: string,
id: string
}

export function AzureProvider({ authorization, token, tenantId, ...rest }: OAuthUserConfig<AzureProfile> & { tenantId: string, profile: NonNullable<OAuthUserConfig<AzureProfile>['profile']> }): OAuthConfig<AzureProfile> {
return {
id: 'azure',
name: 'Microsoft',
type: 'oauth',
version: '2.0',
authorization: {
url: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?response_type=code&response_mode=query`,
params: {
scope: 'https://graph.microsoft.com/user.read',
...typeof authorization === 'string' ? undefined : authorization?.params,
},
...typeof authorization === 'string' ? { url: authorization } : authorization,
},
token: {
url: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
params: {
grant_type: 'authorization_code',
...typeof token === 'string' ? {} : token?.params,
},
...typeof token === 'string' ? { url: token } : token,
},
userinfo: 'https://graph.microsoft.com/v1.0/me/',
...rest,
};
}
36 changes: 36 additions & 0 deletions packages/backend/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { prisma } from "~database/src";

interface ConfigurationSettings {
default: string;
writeOnly: boolean;
}

function config(settings?: Partial<ConfigurationSettings>): ConfigurationSettings {
return Object.assign({
default: '',
writeOnly: false,
} satisfies ConfigurationSettings, settings);
}

export const configOptions = {
'auth.azure.loginName': config({ default: 'Microsoft' }),
'auth.azure.tenantId': config(),
'auth.azure.clientId': config(),
'auth.azure.clientSecret': config({ writeOnly: true }),
} as const;

export async function getConfigValue(key: keyof typeof configOptions) {
const result = await prisma.systemConfiguration.findUnique({
where: { key },
select: { value: true },
});
return result?.value ?? configOptions[key].default;
}

export async function getConfigValues<T extends (keyof typeof configOptions)[]>(...options: T): Promise<{ [key in T[number]]: string }> {
const result = await prisma.systemConfiguration.findMany({
where: { key: { in: options } },
select: { key: true, value: true },
});
return Object.fromEntries(result.map(r => [r.key, r.value])) as any;
}
4 changes: 2 additions & 2 deletions packages/backend/src/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Session, getServerSession } from 'next-auth';
import { prisma } from '~database';
import { hasScope } from './scopes';
import { IMPERSONATION_COOKIE } from '~api/constants';
import { nextAuthOptions } from './nextAuth';
import { getNextAuthOptions } from './nextAuth';
import { GetServerSidePropsContext } from 'next';

export type IdentityError
Expand All @@ -12,7 +12,7 @@ export type IdentityError
;

export default async function getIdentity(req: GetServerSidePropsContext['req'], res: GetServerSidePropsContext['res']): Promise<IdentityError | Session> {
const nextAuthSession = await getServerSession(req, res, nextAuthOptions);
const nextAuthSession = await getServerSession(req, res, await getNextAuthOptions());

if (!nextAuthSession) {
return 'not-logged-in';
Expand Down
Loading

0 comments on commit a1f06d7

Please sign in to comment.