add OIDC support
This commit is contained in:
parent
78e725bd05
commit
8a5ba389fc
7 changed files with 332 additions and 1 deletions
|
|
@ -138,6 +138,7 @@
|
|||
"ip-cidr": "4.0.2",
|
||||
"ipaddr.js": "2.2.0",
|
||||
"is-svg": "6.1.0",
|
||||
"jose": "^6.1.3",
|
||||
"js-yaml": "4.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "8.3.3",
|
||||
|
|
|
|||
|
|
@ -161,6 +161,17 @@ type Source = {
|
|||
customHtml?: {
|
||||
head?: string;
|
||||
}
|
||||
|
||||
oidc: {
|
||||
name: string;
|
||||
authEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scope: string[];
|
||||
autoRegister: boolean;
|
||||
usernameClaim: string;
|
||||
} | undefined;
|
||||
};
|
||||
|
||||
export type PrivateNetworkSource = string | { network?: string, ports?: number[] };
|
||||
|
|
@ -339,6 +350,17 @@ export type Config = {
|
|||
} | undefined;
|
||||
|
||||
pidFile: string;
|
||||
|
||||
oidc: {
|
||||
name: string;
|
||||
authEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
scope: string[];
|
||||
autoRegister: boolean;
|
||||
usernameClaim: string;
|
||||
} | undefined;
|
||||
filePermissionBits?: string;
|
||||
|
||||
activityLogging: {
|
||||
|
|
@ -521,6 +543,7 @@ export function loadConfig(loggerService: LoggerService): Config {
|
|||
customHtml: {
|
||||
head: config.customHtml?.head ?? '',
|
||||
},
|
||||
oidc: config.oidc,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
|
|||
import { ReversiChannelService } from './api/stream/channels/reversi.js';
|
||||
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
|
||||
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
|
||||
import { OidcApiService } from './api/OidcApiService.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -128,6 +129,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
|
|||
ApiStatusMastodon,
|
||||
ApiTimelineMastodon,
|
||||
ServerUtilityService,
|
||||
OidcApiService,
|
||||
],
|
||||
exports: [
|
||||
ServerService,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { SignupApiService } from './SignupApiService.js';
|
|||
import { SigninApiService } from './SigninApiService.js';
|
||||
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { OidcApiService } from './OidcApiService.js';
|
||||
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -43,7 +44,11 @@ export class ApiServerService {
|
|||
private signupApiService: SignupApiService,
|
||||
private signinApiService: SigninApiService,
|
||||
private signinWithPasskeyApiService: SigninWithPasskeyApiService,
|
||||
<<<<<<< HEAD
|
||||
private cacheService: CacheService,
|
||||
=======
|
||||
private oidcApiService: OidcApiService,
|
||||
>>>>>>> cd7330e1a2 (add OIDC support)
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
}
|
||||
|
|
@ -210,6 +215,14 @@ export class ApiServerService {
|
|||
}
|
||||
});
|
||||
|
||||
fastify.get('/oidc/meta', (request, reply) => this.oidcApiService.meta(request, reply));
|
||||
fastify.get<{
|
||||
Querystring: {
|
||||
code?: string;
|
||||
error?: string;
|
||||
};
|
||||
}>('/oidc/callback', (request, reply) => this.oidcApiService.callback(request, reply));
|
||||
|
||||
// Make sure any unknown path under /api returns HTTP 404 Not Found,
|
||||
// because otherwise ClientServerService will return the base client HTML
|
||||
// page with HTTP 200.
|
||||
|
|
|
|||
216
packages/backend/src/server/api/OidcApiService.ts
Normal file
216
packages/backend/src/server/api/OidcApiService.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: skye and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as jose from 'jose';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
UsedUsernamesRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { SignupService } from '@/core/SignupService.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
export class OidcApiService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.usedUsernamesRepository)
|
||||
private usedUsernamesRepository: UsedUsernamesRepository,
|
||||
|
||||
private rateLimiterService: RateLimiterService,
|
||||
private signinService: SigninService,
|
||||
private signupService: SignupService,
|
||||
private metaService: MetaService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async meta(
|
||||
_request: FastifyRequest,
|
||||
_reply: FastifyReply,
|
||||
) {
|
||||
if (this.config.oidc === undefined) {
|
||||
return {
|
||||
enabled: false,
|
||||
};
|
||||
}
|
||||
const url = new URL(this.config.oidc.authEndpoint);
|
||||
url.searchParams.append('client_id', this.config.oidc.clientId);
|
||||
url.searchParams.append('redirect_uri', `${this.config.apiUrl}/oidc/callback`);
|
||||
url.searchParams.append('response_type', 'code');
|
||||
url.searchParams.append('scope', this.config.oidc.scope.join(' '));
|
||||
return {
|
||||
enabled: true,
|
||||
name: this.config.oidc.name,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async callback(
|
||||
request: FastifyRequest<{
|
||||
Querystring: {
|
||||
code?: string;
|
||||
error?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
reply.header('Access-Control-Allow-Origin', this.config.url);
|
||||
reply.header('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
const instance = await this.metaService.fetch(true);
|
||||
|
||||
const query = request.query;
|
||||
|
||||
function error(status: number, error: { id: string }) {
|
||||
reply.code(status);
|
||||
return { error };
|
||||
}
|
||||
|
||||
if (query.error !== undefined) {
|
||||
return error(403, {
|
||||
id: 'bf721f0c-ed11-4c94-bc0a-84aaf8b7431a',
|
||||
});
|
||||
}
|
||||
|
||||
if (query.code === undefined) {
|
||||
return error(400, {
|
||||
id: '797c0ae2-2eb9-402d-a2d6-090b5f3f3686',
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.oidc === undefined) {
|
||||
return error(403, {
|
||||
id: '726c5012-eec4-49c8-b613-74fb23c2bdce',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
||||
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
||||
} catch (err) {
|
||||
reply.code(429);
|
||||
return {
|
||||
error: {
|
||||
message: 'Too many failed attempts to sign in. Try again later.',
|
||||
code: 'TOO_MANY_AUTHENTICATION_FAILURES',
|
||||
id: '22d05606-fbcf-421a-a2db-b32610dcfd1b',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const tokenResponse: { id_token: string } | { error: string } = await (await this.httpRequestService.send(this.config.oidc.tokenEndpoint, {
|
||||
body: new URLSearchParams({
|
||||
code: query.code,
|
||||
client_id: this.config.oidc.clientId,
|
||||
client_secret: this.config.oidc.clientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${this.config.apiUrl}/oidc/callback`,
|
||||
}).toString(),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
method: 'POST',
|
||||
})).json() as { id_token: string } | { error: string };
|
||||
|
||||
if ('error' in tokenResponse) {
|
||||
return error(403, {
|
||||
id: 'afae6924-7744-4efb-aed1-cfda2ddd8c43',
|
||||
});
|
||||
}
|
||||
|
||||
const token = jose.decodeJwt<Record<string, string>>(tokenResponse.id_token);
|
||||
|
||||
const username = token[this.config.oidc.usernameClaim];
|
||||
|
||||
if (username === undefined) {
|
||||
return error(400, {
|
||||
id: '3b02c880-cd46-4886-9cda-325529340226',
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch user
|
||||
let user = await this.usersRepository.findOneBy({
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
}) as MiLocalUser;
|
||||
|
||||
if (user == null) {
|
||||
if (this.config.oidc.autoRegister) {
|
||||
try {
|
||||
if (await this.usedUsernamesRepository.exists({ where: { username: username.toLowerCase() } })) {
|
||||
throw new FastifyReplyError(400, 'USED_USERNAME');
|
||||
}
|
||||
const { account } = await this.signupService.signup({
|
||||
username,
|
||||
});
|
||||
user = account as MiLocalUser;
|
||||
} catch (err) {
|
||||
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());
|
||||
}
|
||||
} else {
|
||||
return error(404, {
|
||||
id: '6cc579cc-885d-43d8-95c2-b8c7fc963280',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return error(403, {
|
||||
id: 'e03a5f46-d309-4865-9b69-56282d94e1eb',
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.approved && instance.approvalRequiredForSignup) {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: {
|
||||
message: 'The account has not been approved by an admin yet. Try again later.',
|
||||
code: 'NOT_APPROVED',
|
||||
id: '22d05606-fbcf-421a-a2db-b32241faft1b',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
|
||||
|
||||
const resp = this.signinService.signin(request, reply, user);
|
||||
reply.type('text/html');
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<title>Please wait...</title>
|
||||
</head>
|
||||
<body>
|
||||
Please wait...
|
||||
<script>
|
||||
window.opener.postMessage({
|
||||
type: 'login-ok',
|
||||
data: ${JSON.stringify(resp)}
|
||||
}, '${this.config.url}');
|
||||
window.close();
|
||||
</script>
|
||||
</body>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -60,6 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="waiting" :class="$style.waitingRoot">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
<MkButton v-if="oidcLogin" v-oidc type="button" large primary rounded style="margin: 0 auto;" @click="oidcPopup">Sign in with {{ oidcName }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@gi
|
|||
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
|
||||
import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
|
||||
import type { PwResponse } from '@/components/MkSignin.password.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import { showSuspendedDialog } from '@/utility/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { showSystemAccountDialog } from '@/utility/show-system-account-dialog.js';
|
||||
|
|
@ -105,6 +106,47 @@ const needCaptcha = ref(false);
|
|||
const userInfo = ref<null | Misskey.entities.UserDetailed>(null);
|
||||
const password = ref('');
|
||||
|
||||
const oidcName = ref('OpenID Connect');
|
||||
const oidcLogin = ref(false);
|
||||
|
||||
let oidcUrl: string | undefined;
|
||||
|
||||
misskeyApiGet('oidc/meta').then((resp: {
|
||||
enabled: false
|
||||
} | {
|
||||
enabled: true,
|
||||
name: string,
|
||||
url: string,
|
||||
}) => {
|
||||
oidcLogin.value = resp.enabled;
|
||||
if (resp.enabled) {
|
||||
oidcName.value = resp.name;
|
||||
oidcUrl = resp.url;
|
||||
}
|
||||
});
|
||||
|
||||
const vOidc = {
|
||||
mounted: (el) => {
|
||||
window.addEventListener('message', oidcEvent);
|
||||
},
|
||||
unmounted: (el) => {
|
||||
window.removeEventListener('message', oidcEvent);
|
||||
},
|
||||
};
|
||||
|
||||
async function oidcPopup() {
|
||||
window.open(oidcUrl, undefined, 'popup');
|
||||
}
|
||||
|
||||
function oidcEvent(event) {
|
||||
const data = event.data;
|
||||
if (typeof data === 'object' && 'type' in data && data.type === 'login-ok') {
|
||||
const res = (data as { type: 'login-ok', data: unknown }).data;
|
||||
emit('login', res);
|
||||
return onLoginSucceeded(res);
|
||||
}
|
||||
}
|
||||
|
||||
//#region Passkey Passwordless
|
||||
const credentialRequest = shallowRef<CredentialRequestOptions | null>(null);
|
||||
const passkeyContext = ref('');
|
||||
|
|
|
|||
34
pnpm-lock.yaml
generated
34
pnpm-lock.yaml
generated
|
|
@ -258,6 +258,9 @@ importers:
|
|||
is-svg:
|
||||
specifier: 6.1.0
|
||||
version: 6.1.0
|
||||
jose:
|
||||
specifier: ^6.1.3
|
||||
version: 6.1.3
|
||||
js-yaml:
|
||||
specifier: 4.1.0
|
||||
version: 4.1.0
|
||||
|
|
@ -4199,9 +4202,17 @@ packages:
|
|||
resolution: {integrity: sha1-50hzNdDZoxqKTCy+G9PdW3VDVso=, tarball: https://activitypub.software/api/v4/projects/229/packages/npm/@transfem-org/cli-highlight/-/@transfem-org/cli-highlight-2.1.13.tgz}
|
||||
engines: {node: ^22.0.0}
|
||||
|
||||
<<<<<<< HEAD
|
||||
'@transfem-org/sfm-js@0.26.1':
|
||||
resolution: {integrity: sha1-42SS8z0rQLz7gjyg5fbNeUIHHVk=, tarball: https://activitypub.software/api/v4/projects/2/packages/npm/@transfem-org/sfm-js/-/@transfem-org/sfm-js-0.26.1.tgz}
|
||||
engines: {node: ^22.0.0}
|
||||
=======
|
||||
'@transfem-org/sfm-js@0.24.6':
|
||||
resolution: {integrity: sha1-7t+TkCd3PZk+RbbrGbZ/iMs2y7o=, tarball: https://activitypub.software/api/v4/projects/2/packages/npm/@transfem-org/sfm-js/-/@transfem-org/sfm-js-0.24.6.tgz}
|
||||
|
||||
'@transfem-org/summaly@5.2.1':
|
||||
resolution: {integrity: sha1-bSQjLRWt0g/UkhYnGjT5/UPig20=, tarball: https://activitypub.software/api/v4/projects/217/packages/npm/@transfem-org/summaly/-/@transfem-org/summaly-5.2.1.tgz}
|
||||
>>>>>>> cd7330e1a2 (add OIDC support)
|
||||
|
||||
'@transfem-org/summaly@5.2.3':
|
||||
resolution: {integrity: sha1-ru+BBpNlr8yJwI+KWp5I5ZHvnTM=, tarball: https://activitypub.software/api/v4/projects/217/packages/npm/@transfem-org/summaly/-/@transfem-org/summaly-5.2.3.tgz}
|
||||
|
|
@ -7524,6 +7535,7 @@ packages:
|
|||
joi@17.13.3:
|
||||
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
|
||||
|
||||
<<<<<<< HEAD
|
||||
joi@18.0.1:
|
||||
resolution: {integrity: sha512-IiQpRyypSnLisQf3PwuN2eIHAsAIGZIrLZkd4zdvIar2bDyhM91ubRjy8a3eYablXsh9BeI/c7dmPYHca5qtoA==}
|
||||
engines: {node: '>= 20'}
|
||||
|
|
@ -7531,6 +7543,14 @@ packages:
|
|||
js-beautify@1.15.4:
|
||||
resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==}
|
||||
engines: {node: '>=14'}
|
||||
=======
|
||||
jose@5.10.0:
|
||||
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||
|
||||
js-beautify@1.14.9:
|
||||
resolution: {integrity: sha512-coM7xq1syLcMyuVGyToxcj2AlzhkDjmfklL8r0JgJ7A76wyGMpJ1oA35mr4APdYNO/o/4YY8H54NQIJzhMbhBg==}
|
||||
engines: {node: '>=12'}
|
||||
>>>>>>> cd7330e1a2 (add OIDC support)
|
||||
hasBin: true
|
||||
|
||||
js-cookie@3.0.5:
|
||||
|
|
@ -14375,7 +14395,15 @@ snapshots:
|
|||
|
||||
'@transfem-org/summaly@5.2.3':
|
||||
dependencies:
|
||||
<<<<<<< HEAD
|
||||
cheerio: 1.1.2
|
||||
=======
|
||||
'@twemoji/parser': 15.0.0
|
||||
|
||||
'@transfem-org/summaly@5.2.1':
|
||||
dependencies:
|
||||
cheerio: 1.0.0
|
||||
>>>>>>> cd7330e1a2 (add OIDC support)
|
||||
escape-regexp: 0.0.1
|
||||
got: 14.4.9
|
||||
html-entities: 2.6.0
|
||||
|
|
@ -18515,6 +18543,7 @@ snapshots:
|
|||
'@sideway/formula': 3.0.1
|
||||
'@sideway/pinpoint': 2.0.0
|
||||
|
||||
<<<<<<< HEAD
|
||||
joi@18.0.1:
|
||||
dependencies:
|
||||
'@hapi/address': 5.1.1
|
||||
|
|
@ -18526,6 +18555,11 @@ snapshots:
|
|||
'@standard-schema/spec': 1.0.0
|
||||
|
||||
js-beautify@1.15.4:
|
||||
=======
|
||||
jose@5.10.0: {}
|
||||
|
||||
js-beautify@1.14.9:
|
||||
>>>>>>> cd7330e1a2 (add OIDC support)
|
||||
dependencies:
|
||||
config-chain: 1.1.13
|
||||
editorconfig: 1.0.4
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue