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:
parent
f7bc349e03
commit
875a828831
17 changed files with 655 additions and 261 deletions
52
packages/connector-azuread/README.md
Normal file
52
packages/connector-azuread/README.md
Normal file
|
@ -0,0 +1,52 @@
|
|||
# Azure AD connector
|
||||
|
||||
The Azure AD connector provides a succinct way for your application to use Azure’s 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)
|
6
packages/connector-azuread/docs/config-template.json
Normal file
6
packages/connector-azuread/docs/config-template.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"clientId": "<client-id>",
|
||||
"clientSecret": "<client-secret>",
|
||||
"tenantId": "<tenant-id>",
|
||||
"cloudInstance": "https://login.microsoftonline.com/"
|
||||
}
|
8
packages/connector-azuread/jest.config.ts
Normal file
8
packages/connector-azuread/jest.config.ts
Normal 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;
|
12
packages/connector-azuread/logo.svg
Normal file
12
packages/connector-azuread/logo.svg
Normal 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 |
60
packages/connector-azuread/package.json
Normal file
60
packages/connector-azuread/package.json
Normal 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"
|
||||
}
|
25
packages/connector-azuread/src/constant.ts
Normal file
25
packages/connector-azuread/src/constant.ts
Normal 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;
|
11
packages/connector-azuread/src/index.test.ts
Normal file
11
packages/connector-azuread/src/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
169
packages/connector-azuread/src/index.ts
Normal file
169
packages/connector-azuread/src/index.ts
Normal 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;
|
||||
};
|
||||
}
|
34
packages/connector-azuread/src/types.ts
Normal file
34
packages/connector-azuread/src/types.ts
Normal 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>;
|
10
packages/connector-azuread/tsconfig.base.json
Normal file
10
packages/connector-azuread/tsconfig.base.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "@silverhand/ts-config/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"outDir": "lib",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
5
packages/connector-azuread/tsconfig.build.json
Normal file
5
packages/connector-azuread/tsconfig.build.json
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "./tsconfig.base",
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
7
packages/connector-azuread/tsconfig.json
Normal file
7
packages/connector-azuread/tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "jest", "jest-matcher-specific-error"]
|
||||
},
|
||||
"include": ["src", "jest.config.ts"]
|
||||
}
|
7
packages/connector-azuread/tsconfig.test.json
Normal file
7
packages/connector-azuread/tsconfig.test.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false,
|
||||
"allowJs": true,
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
501
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue