0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(connector): azure active directory connector added (#1662)

* feat(connector): azure active directory connector added

* refactor(connector): apply code review suggestions

* refactor: removed PKCE

* chore: update package and lockfile

* refactor(connector): fix typo

* refactor(connector): polish code

Co-authored-by: Gao Sun <gao@silverhand.io>
This commit is contained in:
Ufuk ARSLAN 2022-07-26 18:31:25 +03:00 committed by GitHub
parent f7bc349e03
commit 875a828831
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 655 additions and 261 deletions

View file

@ -0,0 +1,52 @@
# Azure AD connector
The Azure AD connector provides a succinct way for your application to use Azures OAuth 2.0 authentication system.
**Table of contents**
- [Azure AD connector](#azure-ad-connector)
- [Set up Azure AD in the Azure Portal](#set-up-azure-ad-in-the-azure-portal)
- [Configure your client secret](#configure-your-client-secret)
- [Compose the connector JSON](#compose-the-connector-json)
- [Config types](#config-types)
- [References](#references)
## Set up Azure AD in the Azure Portal
- Visit the [Azure Portal](https://portal.azure.com/#home) and sign in with your Azure account. You need to have an active subscription to access Azure AD.
- Click the **Azure Active Directory** from the services they offer, and click the **App Registrations** from the left menu.
- Click **New Registration** at the top and enter a description, select your **access type** and add your **Redirect URI**, which redirect the user to the application after logging in. In our case, this will be `${your_logto_origin}/callback/azuread-universal`. e.g. `https://logto.dev/callback/azuread-universal`. You need to select Web as Platform.
- If you select **Single Tenant** for access type then you need to enter **TenantID**, else you need to enter `common` as Tenant ID.
## Configure your client secret
- In your newly created project, click the **Certificates & Secrets** to get a client secret, and click the **New client secret** from the top.
- Enter a description and an expiration.
- This will only show your client secret once. Save the **value** to a secure location.
## Compose the connector JSON
- Add your App Registration's **Client ID** into logto json.
- Add your **Client Secret** into logto json.
- Add your App Registration's **Tenant ID** into logto json.
- Add your Microsoft **Login Url** into logto json. This defaults to "https://login.microsoftonline.com/" for many applications, but you can set your custom domain if you have one. (Don't forget the trailing slash)
```jsonc
{
"clientId": "<client-id>",
"clientSecret": "<client-secret>",
"tenantId": "<tenant-id>", // use "common" if you did't select **Single Tenant**
"cloudInstance": "https://login.microsoftonline.com/"
}
```
### Config types
| Name | Type |
| ------------- | ------ |
| clientId | string |
| clientSecret | string |
| tenantId | string |
| cloudInstance | string |
## References
* [Web app that signs in users](https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-web-app-sign-user-overview?tabs=nodejs)

View file

@ -0,0 +1,6 @@
{
"clientId": "<client-id>",
"clientSecret": "<client-secret>",
"tenantId": "<tenant-id>",
"cloudInstance": "https://login.microsoftonline.com/"
}

View file

@ -0,0 +1,8 @@
import { Config, merge } from '@silverhand/jest-config';
const config: Config.InitialOptions = merge({
testEnvironment: 'node',
setupFilesAfterEnv: ['jest-matcher-specific-error'],
});
export default config;

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024">
<defs>
<linearGradient id="a" x1="359" y1="184" x2="747" y2="786" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#54aef0"/>
<stop offset="1" stop-color="#3499e4"/>
</linearGradient>
</defs>
<path fill="#53b1e0" d="M482,422L482,602,512,793.3,907.7,538.9,752,452,482,422Z"/>
<path fill="url(#a)" d="M992,637.6,512,948.9,482,902l30-52.5L938.7,575.2ZM512,75.2,482,272l30,174.4,395.7,92.5Z"/>
<path fill="#9cebff" d="M512,446.4L272,452,116.3,538.9,512,793.3,512,446.4Z"/>
<path fill="#50e6ff" d="M85.3,575.2,512,849.5v99.4L32,637.6Zm31-36.3L512,446.4V75.2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 676 B

View file

@ -0,0 +1,60 @@
{
"name": "@logto/connector-azuread",
"version": "1.0.0-beta.2",
"description": "Azure AD connector implementation.",
"main": "./lib/index.js",
"exports": "./lib/index.js",
"author": "Mobilist Inc. <info@mobilist.com.tr>",
"license": "MPL-2.0",
"private": true,
"files": [
"lib",
"docs",
"logo.svg",
"README.md"
],
"scripts": {
"precommit": "lint-staged",
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"",
"test": "jest",
"test:coverage": "jest --coverage --silent",
"prepack": "pnpm build"
},
"dependencies": {
"@azure/msal-node": "^1.12.0",
"@logto/connector-types": "^1.0.0-beta.2",
"@logto/schemas": "^1.0.0-beta.2",
"@logto/shared": "^1.0.0-beta.1",
"@silverhand/essentials": "^1.1.0",
"@silverhand/jest-config": "^0.17.0",
"axios": "^0.27.2",
"got": "^11.8.2",
"zod": "^3.14.3"
},
"devDependencies": {
"@jest/types": "^27.5.1",
"@silverhand/eslint-config": "^0.17.0",
"@silverhand/ts-config": "^0.17.0",
"@types/jest": "^27.4.1",
"@types/node": "^16.3.1",
"eslint": "^8.19.0",
"jest": "^27.5.1",
"jest-matcher-specific-error": "^1.0.0",
"lint-staged": "^13.0.0",
"nock": "^13.2.2",
"prettier": "^2.3.2",
"ts-jest": "^27.1.1",
"tsc-watch": "^5.0.0",
"typescript": "^4.6.2"
},
"engines": {
"node": "^16.0.0"
},
"eslintConfig": {
"extends": "@silverhand"
},
"prettier": "@silverhand/eslint-config/.prettierrc"
}

View file

@ -0,0 +1,25 @@
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
export const graphAPIEndpoint = 'https://graph.microsoft.com/v1.0/me';
export const scopes = ['User.Read'];
export const defaultMetadata: ConnectorMetadata = {
id: 'azuread-universal',
target: 'azuread',
type: ConnectorType.Social,
platform: ConnectorPlatform.Universal,
name: {
en: 'Azure Active Directory',
'zh-CN': 'Azure Active Directory',
},
logo: './logo.svg',
logoDark: null,
description: {
en: 'Azure Active Directory is the biggest AD provider.',
'zh-CN': 'Azure Active Directory is the biggest AD provider.',
},
readme: './README.md',
configTemplate: './docs/config-template.json',
};
export const defaultTimeout = 5000;

View file

@ -0,0 +1,11 @@
import { GetConnectorConfig } from '@logto/connector-types';
import AzureADConnector from '.';
const getConnectorConfig = jest.fn() as GetConnectorConfig;
describe('Azure AD connector', () => {
it('init without exploding', () => {
expect(() => new AzureADConnector(getConnectorConfig)).not.toThrow();
});
});

View file

@ -0,0 +1,169 @@
import path from 'path';
import {
ConfidentialClientApplication,
AuthorizationCodeRequest,
AuthorizationUrlRequest,
CryptoProvider,
} from '@azure/msal-node';
import {
ConnectorError,
ConnectorErrorCodes,
GetAuthorizationUri,
GetUserInfo,
ConnectorMetadata,
Connector,
SocialConnectorInstance,
GetConnectorConfig,
codeWithRedirectDataGuard,
} from '@logto/connector-types';
import { assert, conditional } from '@silverhand/essentials';
import got, { HTTPError } from 'got';
import { scopes, defaultMetadata, defaultTimeout, graphAPIEndpoint } from './constant';
import {
azureADConfigGuard,
AzureADConfig,
accessTokenResponseGuard,
userInfoResponseGuard,
} from './types';
export default class AzureADConnector implements SocialConnectorInstance<AzureADConfig> {
public metadata: ConnectorMetadata = defaultMetadata;
public clientApplication!: ConfidentialClientApplication;
public authCodeUrlParams!: AuthorizationUrlRequest;
cryptoProvider = new CryptoProvider();
private readonly authCodeRequest!: AuthorizationCodeRequest;
private _connector?: Connector;
public get connector() {
if (!this._connector) {
throw new ConnectorError(ConnectorErrorCodes.General);
}
return this._connector;
}
public set connector(input: Connector) {
this._connector = input;
}
constructor(public readonly getConfig: GetConnectorConfig) {}
public validateConfig(config: unknown): asserts config is AzureADConfig {
const result = azureADConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error);
}
}
public getAuthorizationUri: GetAuthorizationUri = async ({ state, redirectUri }) => {
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
const { clientId, clientSecret, cloudInstance, tenantId } = config;
this.authCodeUrlParams = {
scopes,
state,
redirectUri,
};
this.clientApplication = new ConfidentialClientApplication({
auth: {
clientId,
clientSecret,
authority: new URL(path.join(cloudInstance, tenantId)).toString(),
},
});
const authCodeUrlParameters = {
...this.authCodeUrlParams,
};
const authCodeUrl = await this.clientApplication.getAuthCodeUrl(authCodeUrlParameters);
return authCodeUrl;
};
public getAccessToken = async (code: string, redirectUri: string) => {
const codeRequest = {
...this.authCodeRequest,
redirectUri,
scopes: ['User.Read'],
code,
};
const authResult = await this.clientApplication.acquireTokenByCode(codeRequest);
const result = accessTokenResponseGuard.safeParse(authResult);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { accessToken } = result.data;
assert(accessToken, new ConnectorError(ConnectorErrorCodes.SocialAuthCodeInvalid));
return { accessToken };
};
public getUserInfo: GetUserInfo = async (data) => {
const { code, redirectUri } = await this.authorizationCallbackHandler(data);
const { accessToken } = await this.getAccessToken(code, redirectUri);
const config = await this.getConfig(this.metadata.id);
this.validateConfig(config);
try {
const httpResponse = await got.get(graphAPIEndpoint, {
headers: {
authorization: `Bearer ${accessToken}`,
},
timeout: defaultTimeout,
});
const result = userInfoResponseGuard.safeParse(JSON.parse(httpResponse.body));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error.message);
}
const { id, mail, displayName } = result.data;
return {
id,
email: conditional(mail),
name: conditional(displayName),
};
} catch (error: unknown) {
if (error instanceof HTTPError) {
const { statusCode, body: rawBody } = error.response;
if (statusCode === 401) {
throw new ConnectorError(ConnectorErrorCodes.SocialAccessTokenInvalid);
}
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(rawBody));
}
throw error;
}
};
private readonly authorizationCallbackHandler = async (parameterObject: unknown) => {
const result = codeWithRedirectDataGuard.safeParse(parameterObject);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(parameterObject));
}
return result.data;
};
}

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
export const azureADConfigGuard = z.object({
clientId: z.string(),
clientSecret: z.string(),
cloudInstance: z.string(),
tenantId: z.string(),
});
export type AzureADConfig = z.infer<typeof azureADConfigGuard>;
export const accessTokenResponseGuard = z.object({
accessToken: z.string(),
scopes: z.array(z.string()),
tokenType: z.string(),
});
export type AccessTokenResponse = z.infer<typeof accessTokenResponseGuard>;
export const userInfoResponseGuard = z.object({
id: z.string(),
displayName: z.string().nullish(),
givenName: z.string().nullish(),
surname: z.string().nullish(),
userPrincipalName: z.string().nullish(),
jobTitle: z.string().nullish(),
mail: z.string().nullish(),
mobilePhone: z.string().nullish(),
officeLocation: z.boolean().nullish(),
preferredLanguage: z.string().nullish(),
businessPhones: z.array(z.string()).nullish(),
});
export type UserInfoResponse = z.infer<typeof userInfoResponseGuard>;

View file

@ -0,0 +1,10 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.base",
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base",
"compilerOptions": {
"types": ["node", "jest", "jest-matcher-specific-error"]
},
"include": ["src", "jest.config.ts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"isolatedModules": false,
"allowJs": true,
}
}

View file

@ -24,6 +24,7 @@
"@logto/connector-aliyun-dm": "^1.0.0-beta.2",
"@logto/connector-aliyun-sms": "^1.0.0-beta.2",
"@logto/connector-apple": "^1.0.0-beta.2",
"@logto/connector-azuread": "^1.0.0-beta.2",
"@logto/connector-facebook": "^1.0.0-beta.2",
"@logto/connector-github": "^1.0.0-beta.2",
"@logto/connector-google": "^1.0.0-beta.2",

View file

@ -9,6 +9,7 @@ const defaultPackages = [
'@logto/connector-facebook',
'@logto/connector-github',
'@logto/connector-google',
'@logto/connector-azuread',
'@logto/connector-sendgrid-email',
'@logto/connector-smtp',
'@logto/connector-twilio-sms',

View file

@ -57,6 +57,12 @@ const googleConnector = {
config: {},
createdAt: 1_646_382_233_000,
};
const azureADConnector = {
id: 'azuread-universal',
enabled: false,
config: {},
createdAt: 1_646_382_233_000,
};
const sendGridMailConnector = {
id: 'sendgrid-email-service',
enabled: false,
@ -97,6 +103,7 @@ const connectors = [
facebookConnector,
githubConnector,
googleConnector,
azureADConnector,
sendGridMailConnector,
smtpConnector,
twilioSmsConnector,

501
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff