Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
8acf6867bf oidc format tweaks 2025-12-28 04:25:52 +02:00
fc8e4be066 schlanguish 2025-12-28 02:35:31 +02:00
882f88791e tweaking2 2025-12-28 02:19:27 +02:00
932b0c41ba tweaking 2025-12-28 01:57:54 +02:00
9d2df321da Added nix flake 2025-12-28 01:42:57 +02:00
Skye
8a5ba389fc add OIDC support 2025-12-28 00:39:37 +02:00
10 changed files with 452 additions and 2 deletions

61
flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1766651565,
"narHash": "sha256-QEhk0eXgyIqTpJ/ehZKg9IKS7EtlWxF3N7DXy42zPfU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3e2499d5539c16d0d173ba53552a4ff8547f4539",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

52
flake.nix Normal file
View file

@ -0,0 +1,52 @@
{
description = "Development environment for Sharkey";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
pnpm_9
pkg-config
python3
makeWrapper
cairo
pango
pixman
vips
ffmpeg-headless
jemalloc
];
shellHook = ''
export NODE_ENV=development
export npm_config_nodedir=${pkgs.nodejs}
'';
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
pkgs.ffmpeg-headless
pkgs.jemalloc
pkgs.stdenv.cc.cc
pkgs.cairo
pkgs.pango
pkgs.pixman
pkgs.vips
];
PKG_CONFIG_PATH = "${pkgs.cairo}/lib/pkgconfig:${pkgs.pango}/lib/pkgconfig:${pkgs.pixman}/lib/pkgconfig:${pkgs.vips}/lib/pkgconfig";
};
}
);
}

View file

@ -138,6 +138,7 @@
"ip-cidr": "4.0.2", "ip-cidr": "4.0.2",
"ipaddr.js": "2.2.0", "ipaddr.js": "2.2.0",
"is-svg": "6.1.0", "is-svg": "6.1.0",
"jose": "^6.1.3",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.3", "jsonld": "8.3.3",

View file

@ -161,6 +161,17 @@ type Source = {
customHtml?: { customHtml?: {
head?: string; 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[] }; export type PrivateNetworkSource = string | { network?: string, ports?: number[] };
@ -339,6 +350,17 @@ export type Config = {
} | undefined; } | undefined;
pidFile: string; pidFile: string;
oidc: {
name: string;
authEndpoint: string;
tokenEndpoint: string;
clientId: string;
clientSecret: string;
scope: string[];
autoRegister: boolean;
usernameClaim: string;
} | undefined;
filePermissionBits?: string; filePermissionBits?: string;
activityLogging: { activityLogging: {
@ -521,6 +543,7 @@ export function loadConfig(loggerService: LoggerService): Config {
customHtml: { customHtml: {
head: config.customHtml?.head ?? '', head: config.customHtml?.head ?? '',
}, },
oidc: config.oidc,
}; };
} }

View file

@ -64,6 +64,7 @@ import { ChatRoomChannelService } from './api/stream/channels/chat-room.js';
import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js';
import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
import { OidcApiService } from './api/OidcApiService.js';
@Module({ @Module({
imports: [ imports: [
@ -128,6 +129,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
ApiStatusMastodon, ApiStatusMastodon,
ApiTimelineMastodon, ApiTimelineMastodon,
ServerUtilityService, ServerUtilityService,
OidcApiService,
], ],
exports: [ exports: [
ServerService, ServerService,

View file

@ -19,6 +19,7 @@ import { SignupApiService } from './SignupApiService.js';
import { SigninApiService } from './SigninApiService.js'; import { SigninApiService } from './SigninApiService.js';
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js'; import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { OidcApiService } from './OidcApiService.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
@Injectable() @Injectable()
@ -44,6 +45,7 @@ export class ApiServerService {
private signinApiService: SigninApiService, private signinApiService: SigninApiService,
private signinWithPasskeyApiService: SigninWithPasskeyApiService, private signinWithPasskeyApiService: SigninWithPasskeyApiService,
private cacheService: CacheService, private cacheService: CacheService,
private oidcApiService: OidcApiService,
) { ) {
//this.createServer = this.createServer.bind(this); //this.createServer = this.createServer.bind(this);
} }
@ -210,6 +212,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, // Make sure any unknown path under /api returns HTTP 404 Not Found,
// because otherwise ClientServerService will return the base client HTML // because otherwise ClientServerService will return the base client HTML
// page with HTTP 200. // page with HTTP 200.

View 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 { SkRateLimiterService } from '@/server/SkRateLimiterService.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: SkRateLimiterService,
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>
`;
}
}

View file

@ -30,6 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<!-- username入力 --> <!-- username入力 -->
<form class="_gaps_s" @submit.prevent="emit('usernameSubmitted', username)"> <form class="_gaps_s" @submit.prevent="emit('usernameSubmitted', username)">
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username> <MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username>
@ -48,17 +50,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-device-usb" style="font-size: medium;"></i>{{ i18n.ts.signinWithPasskey }} <i class="ti ti-device-usb" style="font-size: medium;"></i>{{ i18n.ts.signinWithPasskey }}
</MkButton> </MkButton>
</div> </div>
<!-- OIDC login -->
<div :class="$style.orHr">
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
</div>
<div v-if="oidcLogin">
<MkButton type="button" large primary rounded style="margin: 0 auto;" @click="oidcPopup">
Sign in with {{ oidcName }}
</MkButton>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { onBeforeUnmount, onMounted, ref } from 'vue';
import { toUnicode } from 'punycode.js'; import { toUnicode } from 'punycode.js';
import { query, extractDomain } from '@@/js/url.js'; import { query, extractDomain } from '@@/js/url.js';
import { host as configHost } from '@@/js/config.js'; import { host as configHost } from '@@/js/config.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import { misskeyApiGet } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
@ -77,12 +91,52 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'usernameSubmitted', v: string): void; (ev: 'usernameSubmitted', v: string): void;
(ev: 'passkeyClick', v: MouseEvent): void; (ev: 'passkeyClick', v: MouseEvent): void;
(ev: 'oidcLogin', v: any): void;
}>(); }>();
const host = toUnicode(configHost); const host = toUnicode(configHost);
const username = ref(''); const username = 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;
}
});
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('oidcLogin', res);
}
}
onMounted(() => {
window.addEventListener('message', oidcEvent);
});
onBeforeUnmount(() => {
window.removeEventListener('message', oidcEvent);
});
//#region Open on remote //#region Open on remote
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void { function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
switch (options.type) { switch (options.type) {

View file

@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@usernameSubmitted="onUsernameSubmitted" @usernameSubmitted="onUsernameSubmitted"
@passkeyClick="onPasskeyLogin" @passkeyClick="onPasskeyLogin"
@oidcLogin="onOidcLogin"
/> />
<!-- 2. パスワード入力 --> <!-- 2. パスワード入力 -->
@ -56,10 +57,13 @@ SPDX-License-Identifier: AGPL-3.0-only
@done="onPasskeyDone" @done="onPasskeyDone"
@useTotp="onUseTotp" @useTotp="onUseTotp"
/> />
</Transition> </Transition>
<div v-if="waiting" :class="$style.waitingRoot"> <div v-if="waiting" :class="$style.waitingRoot">
<MkLoading/> <MkLoading/>
</div> </div>
</div> </div>
</template> </template>
@ -70,7 +74,7 @@ import { supported as webAuthnSupported, parseRequestOptionsFromJSON } from '@gi
import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill'; import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/browser-ponyfill';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import type { PwResponse } from '@/components/MkSignin.password.vue'; 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 { showSuspendedDialog } from '@/utility/show-suspended-dialog.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { showSystemAccountDialog } from '@/utility/show-system-account-dialog.js'; import { showSystemAccountDialog } from '@/utility/show-system-account-dialog.js';
@ -105,6 +109,13 @@ const needCaptcha = ref(false);
const userInfo = ref<null | Misskey.entities.UserDetailed>(null); const userInfo = ref<null | Misskey.entities.UserDetailed>(null);
const password = ref(''); const password = ref('');
//#region OIDC
function onOidcLogin(res: any): void {
emit('login', res);
onLoginSucceeded(res);
}
//#endregion
//#region Passkey Passwordless //#region Passkey Passwordless
const credentialRequest = shallowRef<CredentialRequestOptions | null>(null); const credentialRequest = shallowRef<CredentialRequestOptions | null>(null);
const passkeyContext = ref(''); const passkeyContext = ref('');

20
pnpm-lock.yaml generated
View file

@ -258,6 +258,9 @@ importers:
is-svg: is-svg:
specifier: 6.1.0 specifier: 6.1.0
version: 6.1.0 version: 6.1.0
jose:
specifier: ^6.1.3
version: 6.1.3
js-yaml: js-yaml:
specifier: 4.1.0 specifier: 4.1.0
version: 4.1.0 version: 4.1.0
@ -7533,6 +7536,9 @@ packages:
engines: {node: '>=14'} engines: {node: '>=14'}
hasBin: true hasBin: true
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
js-cookie@3.0.5: js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'} engines: {node: '>=14'}
@ -14375,7 +14381,15 @@ snapshots:
'@transfem-org/summaly@5.2.3': '@transfem-org/summaly@5.2.3':
dependencies: dependencies:
<<<<<<< HEAD
cheerio: 1.1.2 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 escape-regexp: 0.0.1
got: 14.4.9 got: 14.4.9
html-entities: 2.6.0 html-entities: 2.6.0
@ -18515,6 +18529,7 @@ snapshots:
'@sideway/formula': 3.0.1 '@sideway/formula': 3.0.1
'@sideway/pinpoint': 2.0.0 '@sideway/pinpoint': 2.0.0
<<<<<<< HEAD
joi@18.0.1: joi@18.0.1:
dependencies: dependencies:
'@hapi/address': 5.1.1 '@hapi/address': 5.1.1
@ -18526,6 +18541,11 @@ snapshots:
'@standard-schema/spec': 1.0.0 '@standard-schema/spec': 1.0.0
js-beautify@1.15.4: js-beautify@1.15.4:
=======
jose@5.10.0: {}
js-beautify@1.14.9:
>>>>>>> cd7330e1a2 (add OIDC support)
dependencies: dependencies:
config-chain: 1.1.13 config-chain: 1.1.13
editorconfig: 1.0.4 editorconfig: 1.0.4