Skip to content

Commit 09b2b17

Browse files
committed
chore: add custom idp extension
1 parent 51f8f79 commit 09b2b17

File tree

19 files changed

+865
-0
lines changed

19 files changed

+865
-0
lines changed

custom-idp/6.0.0-alpha/README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Custom IdP Extension
2+
3+
> [!IMPORTANT]
4+
> This extension is designed to work with Webiny `v6.0.0-alpha` exclusively!
5+
6+
This is a reference integration of a custom IdP. The goal of this extension is to demonstrate what Webiny plugins and concepts you need to use to integrate any third pary IdP with Webiny.
7+
8+
> [!WARNING]
9+
> This is in no way a final implementation as different IdPs have their own specifics, and every project has different requirements. This extension gives a solid foundation you can tweak and build upon.
10+
11+
There are two extensions that form the full integration: an [api](./api) and an [admin](./admin) extension.
12+
13+
## API Extension
14+
15+
The `api` extension has these responsibilities:
16+
17+
- Verification of the `idToken` JWT, and validation of the claims on every request to the Webiny API.
18+
- Mapping of token claims to Webiny Identity, which is then used by Webiny for permission checks, etc.
19+
20+
## Admin Extension
21+
22+
The `admin` extension has these responsibilities:
23+
24+
- Interception of the login screen component (no login form, redirects the user to IdP login page)
25+
- Handling of `idToken` and `refreshToken` via query params
26+
- Background token refreshing
27+
- Attaching the `Authorization` header to all API calls
28+
- Storing tokens in localStorage
29+
30+
## Installation
31+
32+
In your Webiny project, run the following command:
33+
34+
```bash
35+
yarn webiny extension custom-idp/6.0.0-alpha
36+
```
37+
38+
## Configuration
39+
40+
### Interaction with Your IdP
41+
42+
Once the extension is installed, you need to provide implementation for `goToLogin`, `onError`, `onLogout`, and `getFreshTokens`.
43+
In your project, open `extensions/customIdp/admin/src/index.tsx`. Here you'll find a reference implementation which you need to update with the specifics of your IdP (redirects to specific URLs, error code mapping, etc.). This file is also an entrypoint to the extension, and a place to configure the `<CustomIdp/>` component.
44+
45+
> [!TIP]
46+
> If you need to change how some parts of the `<CustomIdp/>` work, start by opening the `extensions/customIdp/admin/src/CustomIdp.tsx` component, and see how it interacts with Webiny and the external IdP.
47+
48+
### `idToken` Verification
49+
50+
To verify the `idToken` signature, this implementation uses a shared secret key. You should set your shared secret key in your project's `.env` file:
51+
52+
```dotenv
53+
WEBINY_API_IDP_SHARED_SECRET=my_shared_secret
54+
```
55+
56+
You can find the verification logic in `extensions/customIdp/api/src/createAuthenticator.ts`. Here you can also implement additional verification of token claims.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "@demo/custom-idp-admin",
3+
"main": "src/index.tsx",
4+
"version": "1.0.0",
5+
"keywords": [
6+
"webiny-extension",
7+
"webiny-extension-type:admin"
8+
],
9+
"dependencies": {
10+
"@apollo/react-hooks": "^3.1.5",
11+
"@webiny/admin-ui": "0.0.0",
12+
"@webiny/app-admin": "0.0.0",
13+
"@webiny/app-security": "0.0.0",
14+
"@webiny/app-tenancy": "0.0.0",
15+
"graphql-tag": "^2.12.6",
16+
"jwt-decode": "^4.0.0",
17+
"mobx": "^6.9.0",
18+
"mobx-react-lite": "^3.4.3"
19+
}
20+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useEffect } from "react";
2+
import { setContext } from "apollo-link-context";
3+
import { onError } from "apollo-link-error";
4+
import { ApolloLinkPlugin } from "@webiny/app";
5+
import { useSecurity } from "@webiny/app-security";
6+
import { plugins } from "@webiny/plugins";
7+
import type { ApiError } from "./types";
8+
9+
interface Props {
10+
onError: (error: ApiError) => void;
11+
}
12+
13+
export const AttachAuthorizationHeader = (props: Props) => {
14+
const { getIdToken } = useSecurity();
15+
16+
useEffect(() => {
17+
// Attach Authorization header
18+
const authPlugin = new ApolloLinkPlugin(() => {
19+
return setContext(async (_, { headers }) => {
20+
// If "Authorization" header is already set, don't overwrite it.
21+
if (headers && headers.Authorization) {
22+
return { headers };
23+
}
24+
25+
// Get `idToken` from Security context.
26+
const idToken = await getIdToken();
27+
28+
if (!idToken) {
29+
return { headers };
30+
}
31+
32+
return {
33+
headers: {
34+
...headers,
35+
Authorization: `Bearer ${idToken}`
36+
}
37+
};
38+
});
39+
});
40+
41+
authPlugin.name = "customIdpAuthorizationHeader";
42+
43+
plugins.register(authPlugin);
44+
45+
return () => {
46+
plugins.unregister("customIdpAuthorizationHeader");
47+
};
48+
}, [getIdToken]);
49+
50+
useEffect(() => {
51+
// Catch network errors
52+
plugins.register(
53+
new ApolloLinkPlugin(() => {
54+
return onError(({ networkError }) => {
55+
if (networkError) {
56+
// @ts-expect-error
57+
props.onError(networkError.result);
58+
}
59+
});
60+
})
61+
);
62+
}, []);
63+
64+
return null;
65+
};
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { makeAutoObservable, runInAction } from "mobx";
2+
import { TokenManager } from "./TokenManager";
3+
import { JwtService } from "./JwtService";
4+
import type { GoToLogin, GetFreshTokens, Tokens, OnLogout, LogoutReason } from "./types";
5+
6+
export class Authenticator {
7+
private readonly goToLogin: GoToLogin;
8+
private readonly getFreshTokens: GetFreshTokens;
9+
private readonly refreshInterval: number;
10+
private tokenManager = new TokenManager();
11+
private jwtService = new JwtService();
12+
private intervalId: ReturnType<typeof setInterval> | null = null;
13+
private isAuthenticated = false;
14+
private refreshing = false;
15+
private onLogout: OnLogout;
16+
17+
constructor(
18+
goToLogin: GoToLogin,
19+
getFreshTokens: GetFreshTokens,
20+
onLogout: OnLogout,
21+
refreshIntervalInSeconds: number
22+
) {
23+
this.goToLogin = goToLogin;
24+
this.getFreshTokens = getFreshTokens;
25+
this.onLogout = onLogout;
26+
this.refreshInterval = refreshIntervalInSeconds * 1000;
27+
makeAutoObservable(this);
28+
}
29+
30+
get vm() {
31+
return {
32+
isAuthenticated: this.isAuthenticated,
33+
isRefreshing: this.refreshing
34+
};
35+
}
36+
37+
getIdToken() {
38+
const tokens = this.tokenManager.getTokens();
39+
40+
return tokens?.idToken;
41+
}
42+
43+
public init(tokens?: Tokens) {
44+
this.log("Authenticator.init", tokens);
45+
if (tokens) {
46+
this.tokenManager.setTokens(tokens);
47+
}
48+
49+
this.initialize();
50+
}
51+
52+
public destroy() {
53+
if (this.intervalId) {
54+
clearInterval(this.intervalId);
55+
}
56+
}
57+
58+
// Initialize authentication check and start refresh loop.
59+
private async initialize(): Promise<void> {
60+
const tokens = this.tokenManager.getTokens();
61+
62+
if (!tokens) {
63+
this.setUnauthenticated();
64+
this.goToLogin();
65+
return;
66+
}
67+
68+
const { idToken, refreshToken } = tokens;
69+
70+
if (this.jwtService.isExpired(idToken)) {
71+
if (refreshToken) {
72+
await this.refreshTokens();
73+
} else {
74+
this.logout("noRefreshToken");
75+
return;
76+
}
77+
}
78+
79+
this.setAuthenticated();
80+
81+
this.startTokenRefreshLoop();
82+
}
83+
84+
// Refresh tokens on a regular interval.
85+
private startTokenRefreshLoop(): void {
86+
this.log("Authenticator.startTokenRefreshLoop");
87+
this.intervalId = setInterval(() => {
88+
this.refreshTokens();
89+
}, this.refreshInterval);
90+
}
91+
92+
// Refresh the tokens and update state accordingly.
93+
private async refreshTokens(): Promise<void> {
94+
if (this.refreshing) {
95+
return;
96+
}
97+
98+
const tokens = this.tokenManager.getTokens();
99+
100+
if (!tokens) {
101+
this.logout("noTokens");
102+
return;
103+
}
104+
105+
try {
106+
this.log("Authenticator.refreshTokens");
107+
this.setRefreshing(true);
108+
const freshTokens = await this.getFreshTokens(tokens.refreshToken);
109+
this.tokenManager.setTokens(freshTokens);
110+
this.setAuthenticated();
111+
} catch (err) {
112+
this.log("Authenticator.refreshTokens.error", err);
113+
runInAction(() => {
114+
this.logout("tokenRefreshError");
115+
});
116+
} finally {
117+
this.setRefreshing(false);
118+
}
119+
}
120+
121+
// Force logout the user and redirect.
122+
public logout(reason?: LogoutReason): void {
123+
this.log("Authenticator.logout");
124+
this.tokenManager.clearTokens();
125+
this.setUnauthenticated();
126+
127+
if (this.intervalId !== null) {
128+
clearInterval(this.intervalId);
129+
this.intervalId = null;
130+
}
131+
132+
this.onLogout(reason);
133+
}
134+
135+
private setAuthenticated(): void {
136+
runInAction(() => {
137+
this.isAuthenticated = true;
138+
});
139+
}
140+
141+
private setUnauthenticated(): void {
142+
runInAction(() => {
143+
this.isAuthenticated = false;
144+
});
145+
}
146+
147+
private setRefreshing(flag: boolean): void {
148+
runInAction(() => {
149+
this.refreshing = flag;
150+
});
151+
}
152+
153+
private log(...msg: any[]): void {
154+
if (process.env.NODE_ENV === "development") {
155+
console.log(...msg);
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)