mirror of https://github.com/msgbyte/tailchat
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
333 lines
9.6 KiB
TypeScript
333 lines
9.6 KiB
TypeScript
import { Provider, Configuration, InteractionResults } from 'oidc-provider';
|
|
import { config, TcService, ApiGatewayMixin } from 'tailchat-server-sdk';
|
|
import type { IncomingMessage, ServerResponse } from 'http';
|
|
import ejs from 'ejs';
|
|
import path from 'path';
|
|
import assert from 'assert';
|
|
import qs from 'qs';
|
|
import _ from 'lodash';
|
|
import serve from 'serve-static';
|
|
import { TcOIDCAdapter } from './adapter';
|
|
import { claimUserInfo } from './account';
|
|
import type { UserLoginRes } from '../../../models/user/user';
|
|
|
|
const PORT = process.env.OPENAPI_PORT || config.port + 1;
|
|
const ISSUER = config.apiUrl;
|
|
const IS_PROXY = process.env.OPENAPI_UNDER_PROXY === 'true';
|
|
|
|
const configuration: Configuration = {
|
|
adapter: TcOIDCAdapter,
|
|
// ... see /docs for available configuration
|
|
clients: [],
|
|
pkce: {
|
|
methods: ['S256'],
|
|
required: () => false, // TODO: false in test
|
|
},
|
|
claims: {
|
|
profile: ['nickname', 'discriminator', 'avatar'],
|
|
},
|
|
async findAccount(ctx, id) {
|
|
return {
|
|
accountId: id,
|
|
async claims(use, scope, claims, rejected) {
|
|
const userInfo = await claimUserInfo(id);
|
|
|
|
console.log('[oidc] findAccount', {
|
|
use,
|
|
scope,
|
|
claims,
|
|
rejected,
|
|
userInfo,
|
|
});
|
|
|
|
return userInfo;
|
|
},
|
|
};
|
|
},
|
|
cookies: {
|
|
keys: ['__tailchat_oidc'],
|
|
},
|
|
features: {
|
|
devInteractions: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
interactions: {
|
|
url: (ctx, interaction) => `/open/interaction/${interaction.uid}`,
|
|
},
|
|
// TODO
|
|
// ttl.Session
|
|
// renderError
|
|
};
|
|
|
|
function readIncomingMessageData(req: IncomingMessage) {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.on('data', function (chunk) {
|
|
body += chunk;
|
|
});
|
|
req.on('end', function () {
|
|
resolve(body);
|
|
});
|
|
req.on('error', () => {
|
|
reject();
|
|
});
|
|
});
|
|
}
|
|
|
|
class OIDCService extends TcService {
|
|
provider = this.createOIDCProvider();
|
|
|
|
get serviceName(): string {
|
|
return 'openapi.oidc';
|
|
}
|
|
|
|
private createOIDCProvider() {
|
|
const oidc = new Provider(ISSUER, configuration);
|
|
if (IS_PROXY) {
|
|
oidc.proxy = true;
|
|
this.logger.info('is running under proxy.');
|
|
}
|
|
|
|
return oidc;
|
|
}
|
|
|
|
protected onInit(): void {
|
|
this.registerMixin(ApiGatewayMixin);
|
|
|
|
this.registerSetting('port', PORT);
|
|
this.registerSetting('routes', this.getRoutes());
|
|
}
|
|
|
|
protected async onStart(): Promise<void> {
|
|
this.initListeners();
|
|
}
|
|
|
|
initListeners() {
|
|
function handleClientAuthErrors(
|
|
{ headers: { authorization }, oidc: { body, client } },
|
|
err
|
|
) {
|
|
console.error('handleClientAuthErrors', err);
|
|
if (err.statusCode === 401 && err.message === 'invalid_client') {
|
|
// console.log(err);
|
|
// save error details out-of-bands for the client developers, `authorization`, `body`, `client`
|
|
// are just some details available, you can dig in ctx object for more.
|
|
}
|
|
}
|
|
this.provider.on('authorization.error', handleClientAuthErrors);
|
|
this.provider.on('jwks.error', handleClientAuthErrors);
|
|
this.provider.on('discovery.error', handleClientAuthErrors);
|
|
this.provider.on('end_session.error', handleClientAuthErrors);
|
|
this.provider.on('grant.error', handleClientAuthErrors);
|
|
this.provider.on('introspection.error', handleClientAuthErrors);
|
|
this.provider.on('revocation.error', handleClientAuthErrors);
|
|
this.provider.on('userinfo.error', handleClientAuthErrors);
|
|
}
|
|
|
|
getRoutes() {
|
|
const providerRoute = (req, res) => {
|
|
try {
|
|
this.provider.callback()(req, res);
|
|
} catch (err) {
|
|
console.error('[oidc]', err);
|
|
}
|
|
};
|
|
|
|
return [
|
|
{
|
|
// Reference: https://github.com/moleculerjs/moleculer-web/blob/master/examples/file/index.js
|
|
path: '/open',
|
|
// You should disable body parsers
|
|
bodyParsers: {
|
|
json: false,
|
|
urlencoded: false,
|
|
},
|
|
|
|
whitelist: [],
|
|
|
|
authentication: false,
|
|
authorization: false,
|
|
|
|
aliases: {
|
|
/**
|
|
* 授权交互界面
|
|
*/
|
|
'GET /interaction/:uid': async (
|
|
req: IncomingMessage,
|
|
res: ServerResponse
|
|
) => {
|
|
try {
|
|
const details = await this.provider.interactionDetails(req, res);
|
|
const { uid, prompt, params, session } = details;
|
|
|
|
const client = await this.provider.Client.find(
|
|
String(params.client_id)
|
|
);
|
|
|
|
const promptName = prompt.name;
|
|
const data = {
|
|
logoUri: client.logoUri,
|
|
clientName: client.clientName,
|
|
uid,
|
|
details: prompt.details,
|
|
params,
|
|
session,
|
|
dbg: {
|
|
params: params,
|
|
prompt: prompt,
|
|
},
|
|
};
|
|
|
|
if (promptName === 'login') {
|
|
this.renderHTML(
|
|
res,
|
|
await ejs.renderFile(
|
|
path.resolve(__dirname, './views/login.ejs'),
|
|
data
|
|
)
|
|
);
|
|
} else if (promptName === 'consent') {
|
|
this.renderHTML(
|
|
res,
|
|
await ejs.renderFile(
|
|
path.resolve(__dirname, './views/authorize.ejs'),
|
|
data
|
|
)
|
|
);
|
|
} else {
|
|
this.renderError(res, 'Unknown operation');
|
|
}
|
|
} catch (err) {
|
|
this.renderError(res, err);
|
|
}
|
|
},
|
|
'POST /interaction/:uid/login': async (
|
|
req: IncomingMessage,
|
|
res: ServerResponse
|
|
) => {
|
|
try {
|
|
const {
|
|
prompt: { name },
|
|
} = await this.provider.interactionDetails(req, res);
|
|
assert.equal(name, 'login');
|
|
|
|
const data = await readIncomingMessageData(req);
|
|
const { email, password } = qs.parse(String(data));
|
|
|
|
// Find user
|
|
const user: UserLoginRes = await this.broker.call('user.login', {
|
|
email,
|
|
password,
|
|
});
|
|
|
|
const result = {
|
|
login: {
|
|
accountId: String(user._id),
|
|
...user,
|
|
},
|
|
};
|
|
|
|
await this.provider.interactionFinished(req, res, result, {
|
|
mergeWithLastSubmission: false,
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
this.renderError(res, err);
|
|
}
|
|
},
|
|
'POST /interaction/:uid/confirm': async (
|
|
req: IncomingMessage,
|
|
res: ServerResponse
|
|
) => {
|
|
try {
|
|
const interactionDetails = await this.provider.interactionDetails(
|
|
req,
|
|
res
|
|
);
|
|
const {
|
|
prompt: { name, details },
|
|
params,
|
|
session: { accountId },
|
|
} = interactionDetails;
|
|
assert.equal(name, 'consent');
|
|
|
|
let { grantId } = interactionDetails;
|
|
const grant = grantId
|
|
? // we'll be modifying existing grant in existing session
|
|
await this.provider.Grant.find(grantId)
|
|
: // we're establishing a new grant
|
|
new this.provider.Grant({
|
|
accountId,
|
|
clientId: String(params.client_id),
|
|
});
|
|
|
|
if (Array.isArray(details.missingOIDCScope)) {
|
|
grant.addOIDCScope(details.missingOIDCScope.join(' '));
|
|
}
|
|
if (Array.isArray(details.missingOIDCClaims)) {
|
|
grant.addOIDCClaims(details.missingOIDCClaims);
|
|
}
|
|
if (details.missingResourceScopes) {
|
|
for (const [indicator, scopes] of Object.entries(
|
|
details.missingResourceScopes
|
|
)) {
|
|
grant.addResourceScope(indicator, scopes.join(' '));
|
|
}
|
|
}
|
|
|
|
grantId = await grant.save();
|
|
|
|
const consent: InteractionResults['consent'] = {};
|
|
if (!interactionDetails.grantId) {
|
|
// we don't have to pass grantId to consent, we're just modifying existing one
|
|
consent.grantId = grantId;
|
|
}
|
|
|
|
const result: InteractionResults = { consent };
|
|
await this.provider.interactionFinished(req, res, result, {
|
|
mergeWithLastSubmission: true,
|
|
});
|
|
} catch (err) {
|
|
this.renderError(res, err);
|
|
}
|
|
},
|
|
'GET /auth': providerRoute,
|
|
'GET /auth/:uid': providerRoute,
|
|
'POST /token': providerRoute,
|
|
'POST /me': providerRoute,
|
|
'GET /me': providerRoute,
|
|
'GET /jwks': providerRoute,
|
|
'GET /.well-known/openid-configuration': providerRoute,
|
|
},
|
|
},
|
|
{
|
|
// For css file in development
|
|
path: '/',
|
|
authentication: false,
|
|
authorization: false,
|
|
use: [serve('public')],
|
|
whitelist: [],
|
|
autoAliases: false,
|
|
},
|
|
];
|
|
}
|
|
|
|
async renderError(res: ServerResponse, error: any) {
|
|
res.writeHead(500);
|
|
res.end(
|
|
await ejs.renderFile(path.resolve(__dirname, './views/error.ejs'), {
|
|
text: String(error),
|
|
})
|
|
);
|
|
}
|
|
|
|
renderHTML(res: ServerResponse, html: string) {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/html',
|
|
});
|
|
res.end(html);
|
|
}
|
|
}
|
|
export default OIDCService;
|