Skip to content

Commit 1176dd9

Browse files
committed
Add OAuth2 SSO plugin for AdminForth authentication
0 parents  commit 1176dd9

File tree

5 files changed

+234
-0
lines changed

5 files changed

+234
-0
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
custom/node_modules
3+
dist

custom/OAuthLoginButton.vue

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<template>
2+
<button
3+
@click="handleLogin"
4+
class="border w-full rounded-full py-2 px-4 font-bold flex items-center justify-center hover:bg-gray-50"
5+
>
6+
<div v-html="meta.icon" class="w-6 h-6 mr-4" :alt="providerName" />
7+
<span class="font-bold">Login with {{ providerName }}</span>
8+
</button>
9+
</template>
10+
11+
<script setup>
12+
import { computed } from 'vue';
13+
const props = defineProps({
14+
meta: {
15+
type: Object,
16+
required: true
17+
}
18+
});
19+
const providerName = computed(() => {
20+
return props.meta.provider.replace('AdminForthAdapter', '').replace('Oauth2', '');
21+
});
22+
23+
const handleLogin = () => {
24+
window.location.href = props.meta.authUrl;
25+
};
26+
</script>

index.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { AdminForthPlugin, Filters, AdminUser } from "adminforth";
2+
import type { IAdminForth, AdminForthResource } from "adminforth";
3+
import { IHttpServer } from "adminforth";
4+
import { randomUUID } from 'crypto';
5+
import type { OAuth2Adapter } from "adminforth";
6+
import { AdminForthDataTypes } from "adminforth";
7+
8+
interface OAuth2SSOPluginOptions {
9+
emailField: string;
10+
emailConfirmedField?: string;
11+
adapters: OAuth2Adapter[];
12+
}
13+
14+
export class OAuth2SSOPlugin extends AdminForthPlugin {
15+
private options: OAuth2SSOPluginOptions;
16+
private adminforth: IAdminForth;
17+
private resource: AdminForthResource;
18+
19+
constructor(options: OAuth2SSOPluginOptions) {
20+
super(options, import.meta.url);
21+
if (!options.emailField) {
22+
throw new Error('OAuth2SSOPlugin: emailField is required');
23+
}
24+
this.options = options;
25+
}
26+
27+
async modifyResourceConfig(adminforth: IAdminForth, resource: AdminForthResource) {
28+
await super.modifyResourceConfig(adminforth, resource);
29+
30+
this.adminforth = adminforth;
31+
this.resource = resource;
32+
33+
// Validate emailField exists in resource
34+
if (!resource.columns.find(col => col.name === this.options.emailField)) {
35+
throw new Error(`OAuth2SSOPlugin: emailField "${this.options.emailField}" not found in resource columns`);
36+
}
37+
38+
// Validate emailConfirmedField if provided
39+
if (this.options.emailConfirmedField) {
40+
const confirmedField = resource.columns.find(col => col.name === this.options.emailConfirmedField);
41+
if (!confirmedField) {
42+
throw new Error(`OAuth2SSOPlugin: emailConfirmedField "${this.options.emailConfirmedField}" not found in resource columns`);
43+
}
44+
if (confirmedField.type !== AdminForthDataTypes.BOOLEAN) {
45+
throw new Error(`OAuth2SSOPlugin: emailConfirmedField "${this.options.emailConfirmedField}" must be a boolean field`);
46+
}
47+
}
48+
49+
// Make sure customization and loginPageInjections exist
50+
if (!adminforth.config.customization) {
51+
adminforth.config.customization = {};
52+
}
53+
if (!adminforth.config.customization.loginPageInjections) {
54+
adminforth.config.customization.loginPageInjections = { underInputs: [] };
55+
}
56+
57+
// Register the component with the correct plugin path
58+
const componentPath = `@@/plugins/${this.constructor.name}/OAuthLoginButton.vue`;
59+
this.componentPath('OAuthLoginButton.vue');
60+
61+
const baseUrl = adminforth.config.baseUrl || '';
62+
this.options.adapters.forEach(adapter => {
63+
const state = Buffer.from(JSON.stringify({
64+
provider: adapter.constructor.name
65+
})).toString('base64');
66+
67+
adminforth.config.customization.loginPageInjections.underInputs.push({
68+
file: componentPath,
69+
meta: {
70+
authUrl: `${adapter.getAuthUrl()}&state=${state}`,
71+
provider: adapter.constructor.name,
72+
baseUrl,
73+
icon: adapter.getIcon?.() || ''
74+
}
75+
});
76+
});
77+
}
78+
79+
async doLogin(email: string, response: any, extra: HttpExtra): Promise<{ error?: string; allowedLogin: boolean; redirectTo?: string; }> {
80+
const username = email;
81+
const user = await this.adminforth.resource(this.resource.resourceId).get([
82+
Filters.EQ(this.options.emailField, email)
83+
]);
84+
85+
if (!user) {
86+
return { error: 'User not found', allowedLogin: false };
87+
}
88+
89+
// If emailConfirmedField is set and the field is false, update it to true
90+
if (this.options.emailConfirmedField && user[this.options.emailConfirmedField] === false) {
91+
await this.adminforth.resource(this.resource.resourceId).update(user.id, {
92+
[this.options.emailConfirmedField]: true
93+
});
94+
}
95+
96+
const adminUser = {
97+
dbUser: user,
98+
pk: user.id,
99+
username,
100+
};
101+
const toReturn = { allowedLogin: true, error: '' };
102+
103+
await this.adminforth.restApi.processLoginCallbacks(adminUser, toReturn, response, extra);
104+
105+
if (toReturn.allowedLogin) {
106+
this.adminforth.auth.setAuthCookie({
107+
response,
108+
username,
109+
pk: user.id,
110+
expireInDays: this.adminforth.config.auth.rememberMeDays
111+
});
112+
}
113+
114+
return toReturn;
115+
}
116+
117+
setupEndpoints(server: IHttpServer) {
118+
server.endpoint({
119+
method: 'GET',
120+
path: '/oauth/callback',
121+
noAuth: true,
122+
handler: async ({ query, response, headers, cookies, requestUrl }) => {
123+
const { code, state } = query;
124+
if (!code) {
125+
return { error: 'No authorization code provided' };
126+
}
127+
128+
try {
129+
// The provider information is now passed through the state parameter
130+
const providerState = JSON.parse(Buffer.from(state, 'base64').toString());
131+
const provider = providerState.provider;
132+
133+
const adapter = this.options.adapters.find(a =>
134+
a.constructor.name === provider
135+
);
136+
137+
if (!adapter) {
138+
return { error: 'Invalid OAuth provider' };
139+
}
140+
141+
const userInfo = await adapter.getTokenFromCode(code);
142+
143+
let user = await this.adminforth.resource(this.resource.resourceId).get([
144+
Filters.EQ(this.options.emailField, userInfo.email)
145+
]);
146+
147+
if (!user) {
148+
// When creating a new user, set emailConfirmedField to true if it's configured
149+
const createData: any = {
150+
id: randomUUID(),
151+
created_at: new Date().toISOString(),
152+
[this.options.emailField]: userInfo.email,
153+
role: 'user',
154+
password_hash: ''
155+
};
156+
157+
if (this.options.emailConfirmedField) {
158+
createData[this.options.emailConfirmedField] = true;
159+
}
160+
161+
user = await this.adminforth.resource(this.resource.resourceId).create(createData);
162+
}
163+
164+
return await this.doLogin(userInfo.email, response, {
165+
headers,
166+
cookies,
167+
requestUrl,
168+
query,
169+
body: {}
170+
});
171+
} catch (error) {
172+
console.error('OAuth authentication error:', error);
173+
return {
174+
error: 'Authentication failed',
175+
redirectTo: '/login'
176+
};
177+
}
178+
}
179+
});
180+
}
181+
}

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "adminforth-sso-auth",
3+
"version": "1.0.0",
4+
"main": "index.ts",
5+
"scripts": {
6+
"prepare": "npm link adminforth"
7+
},
8+
"author": "",
9+
"license": "ISC",
10+
"description": ""
11+
}

0 commit comments

Comments
 (0)