Skip to content

Commit

Permalink
Merge pull request #483 from sinamics/azure
Browse files Browse the repository at this point in the history
Azure AD Oauth Authentication
  • Loading branch information
sinamics authored Aug 9, 2024
2 parents 809b367 + b1b5699 commit 13e1752
Show file tree
Hide file tree
Showing 8 changed files with 163 additions and 70 deletions.
38 changes: 37 additions & 1 deletion docs/docs/Authentication/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Standard OAuth 2.0 is used by various providers, including GitHub and Facebook.


### Callback URL
- `https://awesome.ztnet.com/api/auth/callback/oauth`
- `https://<your_domain>/api/auth/callback/oauth`


## Examples
Expand Down Expand Up @@ -116,6 +116,42 @@ ztnet:
OAUTH_USER_INFO: "https://discord.com/api/users/@me"
```

### Azure Active Directory Configuration

When configuring Azure Active Directory (AAD) for your application, it is crucial to properly set the `OAUTH_WELLKNOWN` URL and other environment variables, as these dictate how the OAuth2 flow will interact with AAD. The `AZURE_AD_TENANT_ID` must be correctly embedded within the `OAUTH_WELLKNOWN` URL to ensure proper communication between your application and Azure AD.

**Documentation:** [Azure Active Directory Documentation](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-overview)


To allow specific Active Directory user access:
- In https://portal.azure.com/ search for "Azure Active Directory", and select your organization.
- Next, go to "App Registration" in the left menu, and create a new one.
- Pay close attention to "Who can use this application or access this API?"
- This allows you to scope access to specific types of user accounts
- Only your tenant, all azure tenants, or all azure tenants and public Microsoft accounts (Skype, Xbox, Outlook.com, etc.)
- When asked for a redirection URL, select the platform type "Web" and use https://yourapplication.com/api/auth/callback/oauth as the URL.

After your App Registration is created, under "Client Credential" create your Client secret.
Now copy your:
- Application (client) ID
- Directory (tenant) ID
- Client secret (value)

Note! replace `<tentant_id>` with your Azure AD tenant ID in the `OAUTH_WELLKNOWN` URL.

```yaml
ztnet:
image: sinamics/ztnet:latest
...
environment:
OAUTH_ALLOW_DANGEROUS_EMAIL_LINKING: "true"
OAUTH_ID: "<copy Application (client) ID here>"
OAUTH_SECRET: "<copy generated client secret value here>"
OAUTH_WELLKNOWN: "https://login.microsoftonline.com/<tentant_id>/v2.0/.well-known/openid-configuration"
```



## Troubleshooting
If you are having trouble with OAuth, please check the docker server logs:
```bash
Expand Down
3 changes: 3 additions & 0 deletions prisma/migrations/20240809164834_oauth_provider/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Account" ADD COLUMN "expires_in" INTEGER,
ADD COLUMN "ext_expires_in" INTEGER;
2 changes: 2 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ model Account {
expires_at Int?
refresh_expires_in Int?
token_type String?
ext_expires_in Int?
expires_in Int?
scope String?
id_token String? @db.Text
session_state String?
Expand Down
31 changes: 22 additions & 9 deletions src/__tests__/pages/auth/signin.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ import * as reactHotToast from "react-hot-toast"; // Importing the module for la

jest.mock("next-auth/react", () => ({
signIn: jest.fn(() => Promise.resolve({ ok: true, error: null })),
getProviders: jest.fn(() =>
Promise.resolve({
oauth: {
id: "oauth",
name: "OAuth",
type: "oauth",
signinUrl: "https://provider.com/oauth",
callbackUrl: "https://yourapp.com/api/auth/callback/oauth",
},
}),
),
}));
jest.mock("next/router", () => ({
useRouter: jest.fn().mockReturnValue({
Expand Down Expand Up @@ -65,11 +76,11 @@ describe("LoginPage", () => {
jest.clearAllMocks();
});

const renderLoginPage = ({ hasOauth = false }) => {
const renderLoginPage = () => {
render(
<QueryClientProvider client={queryClient}>
<NextIntlClientProvider locale="en" messages={enTranslation}>
<LoginPage title="test" hasOauth={hasOauth} oauthExclusiveLogin={false} />
<LoginPage title="test" oauthExlusiveLogin={false} />
</NextIntlClientProvider>
</QueryClientProvider>,
);
Expand All @@ -82,7 +93,7 @@ describe("LoginPage", () => {
refetch: jest.fn(),
});
api.public.getWelcomeMessage.useQuery = useQueryMock;
renderLoginPage({ hasOauth: false });
renderLoginPage();

expect(screen.getByLabelText(/Email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
Expand All @@ -93,7 +104,7 @@ describe("LoginPage", () => {
const signInResponse = { ok: true, error: null };
(signIn as jest.Mock).mockResolvedValueOnce(signInResponse);

renderLoginPage({ hasOauth: false });
renderLoginPage();

const emailInput = screen.getByLabelText(/Email/i);
const passwordInput = screen.getByLabelText(/Password/i);
Expand Down Expand Up @@ -129,7 +140,7 @@ describe("LoginPage", () => {
(signIn as jest.Mock).mockResolvedValue({
error: ErrorCode.IncorrectPassword,
});
renderLoginPage({ hasOauth: false });
renderLoginPage();

const emailInput = screen.getByLabelText(/Email/i);
const passwordInput = screen.getByLabelText(/Password/i);
Expand Down Expand Up @@ -167,7 +178,7 @@ describe("LoginPage", () => {
(signIn as jest.Mock).mockResolvedValue({
error: "email or password is wrong!",
});
renderLoginPage({ hasOauth: false });
renderLoginPage();

const emailInput = screen.getByLabelText(/Email/i);
const submitButton = screen.getByRole("button", { name: /Sign in/i });
Expand All @@ -186,19 +197,21 @@ describe("LoginPage", () => {
});

it("handles OAuth sign-in", async () => {
renderLoginPage({ hasOauth: true });
renderLoginPage();
// Wait for the providers to be fetched and the button to be displayed
await waitFor(() => screen.getByRole("button", { name: /Sign in with OAuth/i }));

const oauthButton = screen.getByRole("button", { name: /Sign in with OAuth/i });
await userEvent.click(oauthButton);

expect(signIn).toHaveBeenCalledWith("oauth");
expect(signIn).toHaveBeenCalledWith("oauth", { redirect: false });
});

it("Enter 2FA code", async () => {
(signIn as jest.Mock).mockResolvedValue({
error: ErrorCode.SecondFactorRequired,
});
renderLoginPage({ hasOauth: false });
renderLoginPage();

const emailInput = screen.getByLabelText(/Email/i);
const passwordInput = screen.getByLabelText(/Password/i);
Expand Down
80 changes: 58 additions & 22 deletions src/components/auth/oauthLogin.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,80 @@
import { signIn } from "next-auth/react";
import { signIn, getProviders } from "next-auth/react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useState, useEffect } from "react";
import { toast } from "react-hot-toast";
import cn from "classnames";

const OauthLogin: React.FC = () => {
interface OAuthProvider {
id: string;
name: string;
type: string;
signinUrl: string;
callbackUrl: string;
}

const OAuthLogin: React.FC = () => {
const router = useRouter();
const { error: oauthError } = router.query;
const [loading, setLoading] = useState(false);
const [providers, setProviders] = useState<Record<string, OAuthProvider>>({});

useEffect(() => {
const fetchProviders = async () => {
try {
const fetchedProviders = await getProviders();

if (fetchedProviders) {
setProviders(fetchedProviders);
}
} catch (error) {
console.error("Failed to fetch providers:", error);
toast.error("Failed to load login options");
}
};

fetchProviders();
}, []);

const oAuthHandler = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault();
const oAuthHandler = async (providerId: string) => {
setLoading(true);

try {
await signIn("oauth");
if (!oauthError) {
const result = await signIn(providerId, { redirect: false });

if (result?.error) {
toast.error(`Error occurred: ${result.error}`, { duration: 10000 });
} else if (result?.ok) {
await router.push("/network");
} else {
toast.error(`Error occurred: ${oauthError}` as string, { duration: 10000 });
}
} catch (_error) {
toast.error(`Error occurred: ${oauthError}` as string);
toast.error("Unexpected error occurred", { duration: 10000 });
} finally {
setLoading(false);
}
};

// Convert providers object to array and sort by name
const sortedProviders = Object.values(providers)
.filter((provider) => provider.type === "oauth")
.sort((a, b) => a.name.localeCompare(b.name));
return (
<button
type="button"
onClick={oAuthHandler}
className={cn(
"btn btn-block btn-primary cursor-pointer font-semibold tracking-wide shadow-lg",
)}
>
{loading ? <span className="loading loading-spinner"></span> : null}
Sign in with OAuth
</button>
<div>
{sortedProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => oAuthHandler(provider.id)}
className={cn(
"btn btn-block btn-primary cursor-pointer font-semibold tracking-wide shadow-lg mb-2",
{ "opacity-50 cursor-not-allowed": loading },
)}
disabled={loading}
>
{loading ? <span className="loading loading-spinner"></span> : null}
Sign in with {provider.name}
</button>
))}
</div>
);
};

export default OauthLogin;
export default OAuthLogin;
15 changes: 6 additions & 9 deletions src/pages/auth/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import CredentialsForm from "~/components/auth/credentialsForm";
import Link from "next/link";
import { api } from "~/utils/api";

const Login = ({ title, oauthExlusiveLogin, hasOauth }) => {
const Login = ({ title, oauthExlusiveLogin }) => {
const currentYear = new Date().getFullYear();
const { data: options, isLoading: loadingRegistration } =
api.public.registrationAllowed.useQuery();
Expand All @@ -29,12 +29,10 @@ const Login = ({ title, oauthExlusiveLogin, hasOauth }) => {
<div className="space-y-5">
{!oauthExlusiveLogin && <CredentialsForm />}

{hasOauth && (
<div>
{!oauthExlusiveLogin && <div className="divider">OR</div>}
<OauthLogin />
</div>
)}
<div>
{!oauthExlusiveLogin && <div className="divider">OR</div>}
<OauthLogin />
</div>
{options?.enableRegistration && !loadingRegistration ? (
<div className="pt-5">
<p className="mb-4">Don't have an account?</p>
Expand All @@ -60,12 +58,11 @@ interface Props {
export const getServerSideProps: GetServerSideProps<Props> = async (
context: GetServerSidePropsContext,
) => {
const hasOauth = !!(process.env.OAUTH_ID && process.env.OAUTH_SECRET);
const oauthExlusiveLogin = process.env.OAUTH_EXCLUSIVE_LOGIN === "true";

const session = await getSession(context);
if (!session || !session.user) {
return { props: { hasOauth, oauthExlusiveLogin } };
return { props: { oauthExlusiveLogin } };
}

if (session.user) {
Expand Down
10 changes: 5 additions & 5 deletions src/pages/user-settings/network/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,11 @@ const UserNetworkSetting = () => {
other networks and use the first name found.
</li>
</ul>
<p className="mt-2">
Note: This feature has priority over "Add Member ID as Name". It applies
only to networks where you are the author and doesn't affect networks
managed by others or organizations.
</p>
</p>
<p className="mt-2 text-sm text-gray-500">
Note: This feature has priority over "Add Member ID as Name". It applies
only to networks where you are the author and doesn't affect networks
managed by others or organizations.
</p>
</div>
<input
Expand Down
54 changes: 30 additions & 24 deletions src/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,30 +95,36 @@ const genericOAuthAuthorization = buildAuthorizationConfig(
export const authOptions: NextAuthOptions = {
adapter: MyAdapter,
providers: [
{
id: "oauth",
name: "Oauth",
type: "oauth",
allowDangerousEmailAccountLinking:
Boolean(process.env.OAUTH_ALLOW_DANGEROUS_EMAIL_LINKING) || true,
clientId: process.env.OAUTH_ID,
clientSecret: process.env.OAUTH_SECRET,
wellKnown: process.env.OAUTH_WELLKNOWN,
checks: ["state", "pkce"], // Include 'pkce' if required for your custom OAuth
authorization: genericOAuthAuthorization,
token: process.env.OAUTH_ACCESS_TOKEN_URL,
userinfo: process.env.OAUTH_USER_INFO,
profile(profile) {
return Promise.resolve({
id: profile.sub || profile.id.toString(), // Handle ID based on provider
name: profile.name || profile.login || profile.username,
email: profile.email,
image: profile.picture || profile.avatar_url || profile.image_url,
lastLogin: new Date().toISOString(),
role: "USER",
});
},
},
// Conditionally add Generic OAuth provider
...(process.env.OAUTH_ID && process.env.OAUTH_SECRET
? [
{
id: "oauth",
name: "Oauth",
type: "oauth",
allowDangerousEmailAccountLinking:
Boolean(process.env.OAUTH_ALLOW_DANGEROUS_EMAIL_LINKING) || true,
clientId: process.env.OAUTH_ID,
clientSecret: process.env.OAUTH_SECRET,
checks: ["state", "pkce"] as ("state" | "pkce")[],
wellKnown: process.env.OAUTH_WELLKNOWN,
authorization: genericOAuthAuthorization,
token: process.env.OAUTH_ACCESS_TOKEN_URL,
userinfo: process.env.OAUTH_USER_INFO,
idToken: true,
profile(profile) {
return Promise.resolve({
id: profile.sub || profile.id.toString(),
name: profile.name || profile.login || profile.username,
email: profile.email,
image: profile.picture || profile.avatar_url || profile.image_url,
lastLogin: new Date().toISOString(),
role: "USER",
});
},
} as const, // Add 'as const' to make the provider type narrow to the exact expected values
]
: []),
CredentialsProvider({
// The name to display on the sign in form (e.g. "Sign in with...")
name: "Credentials",
Expand Down

0 comments on commit 13e1752

Please sign in to comment.