mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
oauth-oidc: add generic oidc auth
This commit is contained in:
4 files changed
+148
No files matched your search
@@ -51,6 +51,7 @@
|
|||||||
"semver": "^7.7.4",
|
"semver": "^7.7.4",
|
||||||
"shorty.js": "^1.0.1",
|
"shorty.js": "^1.0.1",
|
||||||
"superagent": "^10.3.0",
|
"superagent": "^10.3.0",
|
||||||
|
"superagent-proxy": "^3.0.0",
|
||||||
"tar": "6.2.1",
|
"tar": "6.2.1",
|
||||||
"thirty-two": "^1.0.2",
|
"thirty-two": "^1.0.2",
|
||||||
"ua-parser-js": "1.0.41"
|
"ua-parser-js": "1.0.41"
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
"@types/notp": "^2.0.5",
|
"@types/notp": "^2.0.5",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@types/superagent": "^8.1.9",
|
"@types/superagent": "^8.1.9",
|
||||||
|
"@types/superagent-proxy": "^3.0.4",
|
||||||
"@types/ua-parser-js": "^0.7.39",
|
"@types/ua-parser-js": "^0.7.39",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"moment": "^2.30.1"
|
"moment": "^2.30.1"
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import _ from 'lodash';
|
|||||||
import { ObjectId as ObjectID } from 'mongodb';
|
import { ObjectId as ObjectID } from 'mongodb';
|
||||||
import Schema from 'schemastery';
|
import Schema from 'schemastery';
|
||||||
import superagent from 'superagent';
|
import superagent from 'superagent';
|
||||||
|
import proxy from 'superagent-proxy';
|
||||||
import { Context } from './context';
|
import { Context } from './context';
|
||||||
|
|
||||||
|
proxy(superagent);
|
||||||
|
|
||||||
export * from './interface';
|
export * from './interface';
|
||||||
export * from './typeutils';
|
export * from './typeutils';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
Context, ForbiddenError, Handler, Schema, Service, superagent,
|
||||||
|
SystemModel, TokenModel, UserFacingError, ValidationError,
|
||||||
|
} from 'hydrooj';
|
||||||
|
|
||||||
|
const ProviderSchema = Schema.object({
|
||||||
|
url: Schema.string().description('OIDC Discovery URL (.well-known/openid-configuration)').required(),
|
||||||
|
clientId: Schema.string().description('Client ID').required(),
|
||||||
|
clientSecret: Schema.string().description('Client Secret').role('secret').required(),
|
||||||
|
name: Schema.string().description('Display name'),
|
||||||
|
icon: Schema.string().description('SVG icon'),
|
||||||
|
canRegister: Schema.boolean().default(true),
|
||||||
|
scope: Schema.string().default('openid email profile'),
|
||||||
|
proxy: Schema.string().description('Proxy URL (e.g. http://host:port, socks5://host:port)'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Config = Schema.object({
|
||||||
|
providers: Schema.dict(ProviderSchema).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface OIDCConfig {
|
||||||
|
authorization_endpoint: string;
|
||||||
|
token_endpoint: string;
|
||||||
|
userinfo_endpoint: string;
|
||||||
|
jwks_uri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveryCache: Record<string, OIDCConfig> = {};
|
||||||
|
|
||||||
|
async function discover(url: string, proxy?: string): Promise<OIDCConfig> {
|
||||||
|
if (discoveryCache[url]) return discoveryCache[url];
|
||||||
|
let req = superagent.get(url).set('Accept', 'application/json');
|
||||||
|
if (proxy) req = req.proxy(proxy);
|
||||||
|
const res = await req;
|
||||||
|
discoveryCache[url] = res.body;
|
||||||
|
return res.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJwtPayload(token: string) {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class LoginWithOIDCService extends Service {
|
||||||
|
static inject = ['oauth'];
|
||||||
|
static Config = Config;
|
||||||
|
|
||||||
|
constructor(ctx: Context, config: ReturnType<typeof Config>) {
|
||||||
|
super(ctx, 'oauth.oidc');
|
||||||
|
|
||||||
|
for (const [key, provider] of Object.entries(config.providers)) {
|
||||||
|
const displayName = provider.name || key;
|
||||||
|
const agent = provider.proxy || undefined;
|
||||||
|
ctx.oauth.provide(`oidc_${key}`, {
|
||||||
|
text: `Login with ${displayName}`,
|
||||||
|
name: displayName,
|
||||||
|
icon: provider.icon || '',
|
||||||
|
canRegister: provider.canRegister,
|
||||||
|
get: async function get(this: Handler) {
|
||||||
|
const oidcConfig = await discover(provider.url, agent);
|
||||||
|
const [state] = await TokenModel.add(TokenModel.TYPE_OAUTH, 600, { redirect: this.request.referer });
|
||||||
|
const url = SystemModel.get('server.url');
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: provider.clientId,
|
||||||
|
response_type: 'code',
|
||||||
|
redirect_uri: `${url}oauth/oidc_${key}/callback`,
|
||||||
|
scope: provider.scope,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
this.response.redirect = `${oidcConfig.authorization_endpoint}?${params.toString()}`;
|
||||||
|
},
|
||||||
|
callback: async function callback(this: Handler, { state, code }) {
|
||||||
|
const s = await TokenModel.get(state, TokenModel.TYPE_OAUTH);
|
||||||
|
if (!s) throw new ValidationError('token');
|
||||||
|
const oidcConfig = await discover(provider.url, agent);
|
||||||
|
const url = SystemModel.get('server.url');
|
||||||
|
let tokenReq = superagent.post(oidcConfig.token_endpoint)
|
||||||
|
.type('form')
|
||||||
|
.send({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: provider.clientId,
|
||||||
|
client_secret: provider.clientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: `${url}oauth/oidc_${key}/callback`,
|
||||||
|
});
|
||||||
|
if (agent) tokenReq = tokenReq.proxy(agent);
|
||||||
|
const tokenRes = await tokenReq;
|
||||||
|
if (tokenRes.body.error) {
|
||||||
|
throw new UserFacingError(
|
||||||
|
tokenRes.body.error, tokenRes.body.error_description, tokenRes.body.error_uri,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const accessToken = tokenRes.body.access_token;
|
||||||
|
const idToken = tokenRes.body.id_token;
|
||||||
|
|
||||||
|
// Try to extract user info from id_token first, then fall back to userinfo endpoint
|
||||||
|
let userInfo: any = null;
|
||||||
|
if (idToken) {
|
||||||
|
userInfo = decodeJwtPayload(idToken);
|
||||||
|
}
|
||||||
|
if (!userInfo?.email && oidcConfig.userinfo_endpoint) {
|
||||||
|
let req = superagent.get(oidcConfig.userinfo_endpoint)
|
||||||
|
.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
.set('Accept', 'application/json');
|
||||||
|
if (agent) req = req.proxy(agent);
|
||||||
|
const res = await req;
|
||||||
|
userInfo = { ...userInfo, ...res.body };
|
||||||
|
}
|
||||||
|
|
||||||
|
await TokenModel.del(s._id, TokenModel.TYPE_OAUTH);
|
||||||
|
if (!userInfo?.email) throw new ForbiddenError("Couldn't retrieve email from OIDC provider.");
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: userInfo.sub || userInfo.email,
|
||||||
|
email: userInfo.email,
|
||||||
|
uname: [userInfo.preferred_username, userInfo.name].filter(Boolean),
|
||||||
|
avatar: userInfo.picture || '',
|
||||||
|
bio: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.i18n.load('zh', {
|
||||||
|
'Login with {0}': '使用 {0} 登录',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@hydrooj/login-with-oidc",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "index.ts",
|
||||||
|
"repository": "https://github.com/hydro-dev/Hydro",
|
||||||
|
"author": "undefined <i@undefined.moe>",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"preferUnplugged": true
|
||||||
|
}
|
||||||
Reference in new issue
Block a user