mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-07-27 03:54:38 +02:00
732 lines
25 KiB
TypeScript
732 lines
25 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
|
|
import dns from 'node:dns/promises';
|
|
import { Inject, Injectable } from '@nestjs/common';
|
|
import * as htmlParser from 'node-html-parser';
|
|
import httpLinkHeader from 'http-link-header';
|
|
import ipaddr from 'ipaddr.js';
|
|
import fastifyCors from '@fastify/cors';
|
|
import checkPKCE from 'oidc-provider/lib/helpers/pkce.js';
|
|
import { permissions as kinds } from 'misskey-js';
|
|
import redirectUri from 'oidc-provider/lib/helpers/redirect_uri.js';
|
|
import {
|
|
AccessDenied,
|
|
InvalidGrant,
|
|
InvalidRequest,
|
|
InvalidScope,
|
|
OIDCProviderError,
|
|
UnsupportedGrantType,
|
|
UnsupportedResponseType,
|
|
} from 'oidc-provider/lib/helpers/errors.js';
|
|
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
|
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
|
import type { Config } from '@/config.js';
|
|
import { DI } from '@/di-symbols.js';
|
|
import { bindThis } from '@/decorators.js';
|
|
import type { AccessTokensRepository, UsersRepository } from '@/models/_.js';
|
|
import { IdService } from '@/core/IdService.js';
|
|
import { CacheService } from '@/core/CacheService.js';
|
|
import type { MiLocalUser } from '@/models/User.js';
|
|
import { MemoryKVCache } from '@/misc/cache.js';
|
|
import { LoggerService } from '@/core/LoggerService.js';
|
|
import Logger from '@/logger.js';
|
|
import { StatusError } from '@/misc/status-error.js';
|
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|
import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js';
|
|
import { OAuthPage } from '@/server/web/views/oauth.js';
|
|
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
|
|
type OIDCProviderErrorInstance = InstanceType<typeof OIDCProviderError>;
|
|
|
|
// TODO: Consider migrating to @node-oauth/oauth2-server once
|
|
// https://github.com/node-oauth/node-oauth2-server/issues/180 is figured out.
|
|
// Upstream the various validations and RFC9207 implementation in that case.
|
|
|
|
// Follows https://indieauth.spec.indieweb.org/#client-identifier
|
|
// This is also mostly similar to https://developers.google.com/identity/protocols/oauth2/web-server#uri-validation
|
|
// although Google has stricter rule.
|
|
function validateClientId(raw: string): URL {
|
|
// "Clients are identified by a [URL]."
|
|
const url = ((): URL => {
|
|
try {
|
|
return new URL(raw);
|
|
} catch {
|
|
throw new InvalidRequest('client_id must be a valid URL');
|
|
}
|
|
})();
|
|
|
|
// "Client identifier URLs MUST have either an https or http scheme"
|
|
// But then again:
|
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.1
|
|
// 'The redirection endpoint SHOULD require the use of TLS as described
|
|
// in Section 1.6 when the requested response type is "code" or "token"'
|
|
const allowedProtocols = process.env.NODE_ENV === 'test' ? ['http:', 'https:'] : ['https:'];
|
|
if (!allowedProtocols.includes(url.protocol)) {
|
|
throw new InvalidRequest('client_id must be a valid HTTPS URL');
|
|
}
|
|
|
|
// "MUST contain a path component (new URL() implicitly adds one)"
|
|
|
|
// "MUST NOT contain single-dot or double-dot path segments,"
|
|
const segments = url.pathname.split('/');
|
|
if (segments.includes('.') || segments.includes('..')) {
|
|
throw new InvalidRequest('client_id must not contain dot path segments');
|
|
}
|
|
|
|
// ("MAY contain a query string component")
|
|
|
|
// "MUST NOT contain a fragment component"
|
|
if (url.hash) {
|
|
throw new InvalidRequest('client_id must not contain a fragment component');
|
|
}
|
|
|
|
// "MUST NOT contain a username or password component"
|
|
if (url.username || url.password) {
|
|
throw new InvalidRequest('client_id must not contain a username or a password');
|
|
}
|
|
|
|
// ("MAY contain a port")
|
|
|
|
// "host names MUST be domain names or a loopback interface and MUST NOT be
|
|
// IPv4 or IPv6 addresses except for IPv4 127.0.0.1 or IPv6 [::1]."
|
|
if (!url.hostname.match(/\.\w+$/) && !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) {
|
|
throw new InvalidRequest('client_id must have a domain name as a host name');
|
|
}
|
|
|
|
return url;
|
|
}
|
|
|
|
interface ClientInformation {
|
|
id: string;
|
|
redirectUris: string[];
|
|
name: string;
|
|
logo: string | null;
|
|
}
|
|
|
|
interface OAuthRequestParameters {
|
|
[key: string]: string | string[] | undefined;
|
|
}
|
|
|
|
interface AuthorizationRequest {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
state?: string;
|
|
scopes: string[];
|
|
codeChallenge: string;
|
|
codeChallengeMethod: string;
|
|
}
|
|
|
|
interface AuthorizationRequestSeed {
|
|
clientInfo: ClientInformation;
|
|
clientId: string;
|
|
redirectUri: string;
|
|
state?: string;
|
|
requestedScope: string[];
|
|
codeChallenge?: string;
|
|
codeChallengeMethod?: string;
|
|
}
|
|
|
|
interface AuthorizationTransaction {
|
|
client: ClientInformation;
|
|
request: AuthorizationRequest;
|
|
}
|
|
|
|
interface AuthorizationCodeGrant {
|
|
clientId: string;
|
|
userId: string;
|
|
redirectUri: string;
|
|
codeChallenge: string;
|
|
scopes: string[];
|
|
grantedToken?: string;
|
|
revoked?: boolean;
|
|
used?: boolean;
|
|
}
|
|
|
|
function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: string): { name: string | null; logo: string | null; } {
|
|
let name: string | null = null;
|
|
let logo: string | null = null;
|
|
|
|
const hApp = doc.querySelector('.h-app');
|
|
if (hApp == null) return { name, logo };
|
|
|
|
const nameEl = hApp.querySelector('.p-name');
|
|
if (nameEl != null) {
|
|
const href = nameEl.attributes.href || nameEl.attributes.src;
|
|
if (href != null && new URL(href, baseUrl).toString() === new URL(id).toString()) {
|
|
name = nameEl.textContent.trim();
|
|
}
|
|
}
|
|
|
|
const logoEl = hApp.querySelector('.u-logo');
|
|
if (logoEl != null) {
|
|
const href = logoEl.attributes.href || logoEl.attributes.src;
|
|
if (href != null) {
|
|
logo = new URL(href, baseUrl).toString();
|
|
}
|
|
}
|
|
|
|
return { name, logo };
|
|
}
|
|
|
|
async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> {
|
|
try {
|
|
const res = await httpRequestService.send(id);
|
|
|
|
const redirectUris: string[] = [];
|
|
let name = id;
|
|
let logo: string | null = null;
|
|
|
|
// https://indieauth.spec.indieweb.org/#redirect-url
|
|
// "The client SHOULD publish one or more <link> tags or Link HTTP headers with a rel attribute
|
|
// of redirect_uri at the client_id URL.
|
|
// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST
|
|
// look for an exact match of the given redirect_uri in the request against the list of
|
|
// redirect_uris discovered after resolving any relative URLs."
|
|
const linkHeader = res.headers.get('link');
|
|
if (linkHeader) {
|
|
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(link => link.uri));
|
|
}
|
|
|
|
const contentType = res.headers.get('content-type');
|
|
const mediaType = contentType ? contentType.split(';')[0].trim() : null;
|
|
if (mediaType === 'application/json') {
|
|
// Client discovery via JSON document (11 July 2024 spec)
|
|
// https://indieauth.spec.indieweb.org/#client-metadata
|
|
// "Clients SHOULD have a JSON [RFC7159] document at their client_id URL containing
|
|
// client metadata defined in [RFC7591], the minimum properties for an IndieAuth
|
|
// client defined below."
|
|
|
|
const json = await res.json() as {
|
|
client_id: string;
|
|
client_name?: string;
|
|
client_uri: string;
|
|
logo_uri?: string;
|
|
redirect_uris?: string[];
|
|
};
|
|
|
|
// https://indieauth.spec.indieweb.org/#client-metadata-li-1
|
|
// "The authorization server MUST verify that the client_id in the document matches the
|
|
// client_id of the URL where the document was retrieved."
|
|
if (json.client_id !== id) {
|
|
throw new InvalidRequest('client_id in the document does not match the client_id URL');
|
|
}
|
|
|
|
// https://indieauth.spec.indieweb.org/#client-metadata-li-1
|
|
// "The client_uri MUST be a prefix of the client_id."
|
|
if (!json.client_uri || !id.startsWith(json.client_uri)) {
|
|
throw new InvalidRequest('client_uri is not a prefix of client_id');
|
|
}
|
|
|
|
if (typeof json.client_name === 'string') {
|
|
name = json.client_name;
|
|
}
|
|
|
|
if (typeof json.logo_uri === 'string') {
|
|
// Since uri can be relative, resolve it against the document URL
|
|
logo = new URL(json.logo_uri, res.url).toString();
|
|
}
|
|
|
|
if (Array.isArray(json.redirect_uris)) {
|
|
redirectUris.push(...json.redirect_uris.filter((uri): uri is string => typeof uri === 'string'));
|
|
}
|
|
} else {
|
|
// Client discovery via HTML microformats (12 February 2022 spec)
|
|
// https://indieauth.spec.indieweb.org/20220212/#client-information-discovery
|
|
// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id,
|
|
// and if there is an [h-app] with a url property matching the client_id URL,
|
|
// then it should use the name and icon and display them on the authorization prompt."
|
|
const text = await res.text();
|
|
const doc = htmlParser.parse(`<div>${text}</div>`);
|
|
|
|
redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href));
|
|
|
|
if (text) {
|
|
const microformats = parseMicroformats(doc, res.url, id);
|
|
if (typeof microformats.name === 'string') {
|
|
name = microformats.name;
|
|
}
|
|
if (typeof microformats.logo === 'string') {
|
|
logo = microformats.logo;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
id,
|
|
redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()),
|
|
name: typeof name === 'string' ? name : id,
|
|
logo,
|
|
};
|
|
} catch (err) {
|
|
logger.error('Error while fetching client information', { err });
|
|
if (err instanceof StatusError) {
|
|
throw new InvalidRequest('Failed to fetch client information');
|
|
}
|
|
if (err instanceof OIDCProviderError) {
|
|
throw err;
|
|
}
|
|
|
|
const wrapped = new InvalidRequest('Failed to parse client information');
|
|
wrapped.status = 500;
|
|
wrapped.statusCode = 500;
|
|
wrapped.error = 'server_error';
|
|
throw wrapped;
|
|
}
|
|
}
|
|
|
|
function firstValue(value: string | string[] | undefined): string | undefined {
|
|
return Array.isArray(value) ? value[0] : value;
|
|
}
|
|
|
|
function normalizeScope(scope: string | string[] | undefined): string[] {
|
|
const raw = Array.isArray(scope) ? scope : scope != null ? [scope] : [];
|
|
return raw.flatMap(value => value.split(/\s+/)).filter(Boolean);
|
|
}
|
|
|
|
function toRequestParameters(body: unknown): OAuthRequestParameters {
|
|
if (body == null || typeof body !== 'object' || Array.isArray(body)) {
|
|
return {};
|
|
}
|
|
|
|
return body as OAuthRequestParameters;
|
|
}
|
|
|
|
function applyNoStore(reply: FastifyReply): void {
|
|
reply.header('Cache-Control', 'no-store');
|
|
reply.header('Pragma', 'no-cache');
|
|
}
|
|
|
|
function createUnsupportedResponseTypeError(): OIDCProviderErrorInstance {
|
|
const error = new UnsupportedResponseType();
|
|
error.status = 501;
|
|
error.statusCode = 501;
|
|
return error;
|
|
}
|
|
|
|
function createForbiddenAccessDenied(description: string): OIDCProviderErrorInstance {
|
|
const error = new AccessDenied(description);
|
|
error.status = 403;
|
|
error.statusCode = 403;
|
|
return error;
|
|
}
|
|
|
|
function normalizeOAuthError(error: unknown): OIDCProviderErrorInstance {
|
|
if (error instanceof OIDCProviderError) {
|
|
return error;
|
|
}
|
|
|
|
const wrapped = new InvalidRequest('request is invalid');
|
|
if (error instanceof Error) {
|
|
wrapped.error_description = error.message;
|
|
}
|
|
return wrapped;
|
|
}
|
|
|
|
function sendOAuthError(reply: FastifyReply, error: OIDCProviderErrorInstance): void {
|
|
applyNoStore(reply);
|
|
reply.code(error.statusCode ?? error.status ?? 400);
|
|
reply.send({
|
|
error: error.error,
|
|
...(error.expose && error.error_description ? { error_description: error.error_description } : {}),
|
|
});
|
|
}
|
|
|
|
function appendIssuer(payload: Record<string, string>, issuerUrl: string): Record<string, string> {
|
|
return {
|
|
...payload,
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
|
|
// "In authorization responses to the client, including error responses,
|
|
// an authorization server supporting this specification MUST indicate its
|
|
// identity by including the iss parameter in the response."
|
|
iss: issuerUrl,
|
|
};
|
|
}
|
|
|
|
function redirectWithQuery(reply: FastifyReply, redirectUriString: string, payload: Record<string, string>): void {
|
|
applyNoStore(reply);
|
|
reply.code(302).redirect(redirectUri(redirectUriString, payload, 'query'));
|
|
}
|
|
|
|
function registerFormBodyParser(fastify: FastifyInstance): void {
|
|
if (fastify.hasContentTypeParser('application/x-www-form-urlencoded')) {
|
|
return;
|
|
}
|
|
|
|
fastify.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_request, body, done) => {
|
|
try {
|
|
const parsed: OAuthRequestParameters = {};
|
|
for (const [key, value] of new URLSearchParams(typeof body === 'string' ? body : body.toString('utf8')).entries()) {
|
|
const current = parsed[key];
|
|
if (current == null) {
|
|
parsed[key] = value;
|
|
} else if (Array.isArray(current)) {
|
|
current.push(value);
|
|
} else {
|
|
parsed[key] = [current, value];
|
|
}
|
|
}
|
|
|
|
done(null, parsed);
|
|
} catch (error) {
|
|
done(error as Error, undefined);
|
|
}
|
|
});
|
|
}
|
|
|
|
@Injectable()
|
|
export class OAuth2ProviderService {
|
|
#authorizationTransactionCache = new MemoryKVCache<AuthorizationTransaction>(1000 * 60 * 5);
|
|
#grantCodeCache = new MemoryKVCache<AuthorizationCodeGrant>(1000 * 60 * 5);
|
|
#logger: Logger;
|
|
|
|
constructor(
|
|
@Inject(DI.config)
|
|
private config: Config,
|
|
private httpRequestService: HttpRequestService,
|
|
@Inject(DI.accessTokensRepository)
|
|
private accessTokensRepository: AccessTokensRepository,
|
|
private idService: IdService,
|
|
@Inject(DI.usersRepository)
|
|
private usersRepository: UsersRepository,
|
|
private cacheService: CacheService,
|
|
private userEntityService: UserEntityService,
|
|
loggerService: LoggerService,
|
|
private htmlTemplateService: HtmlTemplateService,
|
|
) {
|
|
this.#logger = loggerService.getLogger('oauth');
|
|
}
|
|
|
|
async #resolveAuthorizationRequest(params: OAuthRequestParameters): Promise<AuthorizationRequestSeed> {
|
|
const clientId = firstValue(params.client_id);
|
|
const redirectUriValue = firstValue(params.redirect_uri);
|
|
const responseType = firstValue(params.response_type);
|
|
const state = firstValue(params.state);
|
|
const codeChallenge = firstValue(params.code_challenge);
|
|
const codeChallengeMethod = firstValue(params.code_challenge_method);
|
|
const requestedScope = normalizeScope(params.scope);
|
|
|
|
this.#logger.info(`Validating authorization parameters, with client_id: ${clientId}, redirect_uri: ${redirectUriValue}, scope: ${requestedScope.join(' ')}`);
|
|
|
|
if (responseType !== 'code') {
|
|
throw createUnsupportedResponseTypeError();
|
|
}
|
|
|
|
if (!clientId) {
|
|
throw new InvalidRequest('client_id must be provided');
|
|
}
|
|
|
|
const clientUrl = validateClientId(clientId);
|
|
|
|
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
|
// "the server may want to resolve the domain name first and avoid fetching the document
|
|
// if the IP address is within the loopback range defined by [RFC5735]
|
|
// or any other implementation-specific internal IP address."
|
|
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_CHECK_IP_RANGE === '1') {
|
|
const lookup = await dns.lookup(clientUrl.hostname);
|
|
if (ipaddr.parse(lookup.address).range() !== 'unicast') {
|
|
throw new InvalidRequest('client_id resolves to disallowed IP range.');
|
|
}
|
|
}
|
|
|
|
// Find client information from the remote.
|
|
const clientInfo = await discoverClientInformation(this.#logger, this.httpRequestService, clientUrl.href);
|
|
|
|
// Require the redirect URI to be included in an explicit list, per
|
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.1.3
|
|
if (!redirectUriValue || !clientInfo.redirectUris.includes(redirectUriValue)) {
|
|
throw new InvalidRequest('Invalid redirect_uri');
|
|
}
|
|
|
|
return {
|
|
clientInfo,
|
|
clientId: clientInfo.id,
|
|
redirectUri: redirectUriValue,
|
|
state,
|
|
requestedScope,
|
|
codeChallenge,
|
|
codeChallengeMethod,
|
|
};
|
|
}
|
|
|
|
#finalizeAuthorizationRequest(seed: AuthorizationRequestSeed): AuthorizationRequest {
|
|
const scopes = [...new Set(seed.requestedScope)].filter(scope => (<readonly string[]>kinds).includes(scope));
|
|
if (!seed.requestedScope.length || !scopes.length) {
|
|
throw new InvalidScope('`scope` parameter has no known scope', seed.requestedScope.join(' '));
|
|
}
|
|
|
|
// Require PKCE parameters.
|
|
// Recommended by https://indieauth.spec.indieweb.org/#authorization-request, but also prevents downgrade attack:
|
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-pkce-downgrade-attack
|
|
if (typeof seed.codeChallenge !== 'string') {
|
|
throw new InvalidRequest('`code_challenge` parameter is required');
|
|
}
|
|
if (seed.codeChallengeMethod !== 'S256') {
|
|
throw new InvalidRequest('`code_challenge_method` parameter must be set as S256');
|
|
}
|
|
|
|
return {
|
|
clientId: seed.clientId,
|
|
redirectUri: seed.redirectUri,
|
|
state: seed.state,
|
|
scopes,
|
|
codeChallenge: seed.codeChallenge,
|
|
codeChallengeMethod: seed.codeChallengeMethod,
|
|
};
|
|
}
|
|
|
|
async #findUserByLoginToken(loginToken: string): Promise<MiLocalUser> {
|
|
const user = await this.cacheService.localUserByNativeTokenCache.fetch(loginToken,
|
|
() => this.usersRepository.findOneBy({ token: loginToken }) as Promise<MiLocalUser | null>);
|
|
if (!user) {
|
|
throw new InvalidRequest('No such user');
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
async #revokeGrantCode(granted: AuthorizationCodeGrant, code: string): Promise<void> {
|
|
this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`);
|
|
this.#grantCodeCache.delete(code);
|
|
granted.revoked = true;
|
|
if (granted.grantedToken) {
|
|
await this.accessTokensRepository.delete({ token: granted.grantedToken });
|
|
}
|
|
}
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc8414.html
|
|
// https://indieauth.spec.indieweb.org/#indieauth-server-metadata
|
|
public generateRFC8414() {
|
|
return {
|
|
issuer: this.config.url,
|
|
authorization_endpoint: new URL('/oauth/authorize', this.config.url),
|
|
token_endpoint: new URL('/oauth/token', this.config.url),
|
|
scopes_supported: kinds,
|
|
response_types_supported: ['code'],
|
|
grant_types_supported: ['authorization_code'],
|
|
service_documentation: 'https://misskey-hub.net',
|
|
code_challenge_methods_supported: ['S256'],
|
|
authorization_response_iss_parameter_supported: true,
|
|
};
|
|
}
|
|
|
|
@bindThis
|
|
public async createServer(fastify: FastifyInstance): Promise<void> {
|
|
registerFormBodyParser(fastify);
|
|
|
|
fastify.get('/authorize', async (request, reply) => {
|
|
let validatedRedirectUri: string | undefined;
|
|
let state: string | undefined;
|
|
|
|
try {
|
|
const seed = await this.#resolveAuthorizationRequest(request.query as OAuthRequestParameters);
|
|
const { clientInfo } = seed;
|
|
validatedRedirectUri = seed.redirectUri;
|
|
state = seed.state;
|
|
const authorizationRequest = this.#finalizeAuthorizationRequest(seed);
|
|
|
|
const transactionId = secureRndstr(128);
|
|
this.#authorizationTransactionCache.set(transactionId, {
|
|
client: clientInfo,
|
|
request: authorizationRequest,
|
|
});
|
|
|
|
this.#logger.info(`Rendering authorization page for "${clientInfo.name}"`);
|
|
|
|
applyNoStore(reply);
|
|
return await HtmlTemplateService.replyHtml(reply, OAuthPage({
|
|
...await this.htmlTemplateService.getCommonData(),
|
|
transactionId,
|
|
clientName: clientInfo.name,
|
|
clientLogo: clientInfo.logo ?? undefined,
|
|
scope: authorizationRequest.scopes,
|
|
}));
|
|
} catch (error) {
|
|
const oauthError = normalizeOAuthError(error);
|
|
if (validatedRedirectUri && oauthError.allow_redirect && oauthError.error !== 'unsupported_response_type') {
|
|
redirectWithQuery(reply, validatedRedirectUri, appendIssuer({
|
|
error: oauthError.error,
|
|
...(state ? { state } : {}),
|
|
}, this.config.url));
|
|
return;
|
|
}
|
|
|
|
sendOAuthError(reply, oauthError);
|
|
}
|
|
});
|
|
|
|
// https://indieauth.spec.indieweb.org/#profile-url-response
|
|
// 11 July 2024 spec also allows redeeming an authorization code at the
|
|
// authorization endpoint itself when the client only needs the canonical
|
|
// profile URL and not an access token. Misskey currently uses this route
|
|
// only for the browser-based consent UI and issues tokens through
|
|
// /oauth/token, so the profile-only redemption flow remains unimplemented.
|
|
fastify.post('/decision', async (request, reply) => {
|
|
try {
|
|
const body = toRequestParameters(request.body);
|
|
const transactionId = firstValue(body.transaction_id);
|
|
if (!transactionId) {
|
|
throw new InvalidRequest('Missing transaction ID');
|
|
}
|
|
|
|
const transaction = this.#authorizationTransactionCache.get(transactionId);
|
|
if (!transaction) {
|
|
throw createForbiddenAccessDenied('Invalid or expired transaction ID');
|
|
}
|
|
this.#authorizationTransactionCache.delete(transactionId);
|
|
|
|
const cancel = !!firstValue(body.cancel);
|
|
this.#logger.info(`Received the decision. Cancel: ${cancel}`);
|
|
if (cancel) {
|
|
redirectWithQuery(reply, transaction.request.redirectUri, appendIssuer({
|
|
error: 'access_denied',
|
|
...(transaction.request.state ? { state: transaction.request.state } : {}),
|
|
}, this.config.url));
|
|
return;
|
|
}
|
|
|
|
const loginToken = firstValue(body.login_token);
|
|
if (!loginToken) {
|
|
throw new InvalidRequest('No user');
|
|
}
|
|
|
|
this.#logger.info(`Checking the user before sending authorization code to ${transaction.client.id}`);
|
|
const user = await this.#findUserByLoginToken(loginToken);
|
|
|
|
this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${transaction.client.id} through ${transaction.request.redirectUri}, with scope: [${transaction.request.scopes}]`);
|
|
|
|
const code = secureRndstr(128);
|
|
this.#grantCodeCache.set(code, {
|
|
clientId: transaction.client.id,
|
|
userId: user.id,
|
|
redirectUri: transaction.request.redirectUri,
|
|
codeChallenge: transaction.request.codeChallenge,
|
|
scopes: transaction.request.scopes,
|
|
});
|
|
|
|
redirectWithQuery(reply, transaction.request.redirectUri, appendIssuer({
|
|
code,
|
|
...(transaction.request.state ? { state: transaction.request.state } : {}),
|
|
}, this.config.url));
|
|
} catch (error) {
|
|
sendOAuthError(reply, normalizeOAuthError(error));
|
|
}
|
|
});
|
|
|
|
fastify.all('/*', async (_request, reply) => {
|
|
reply.code(404);
|
|
reply.send({
|
|
error: {
|
|
message: 'Unknown OAuth endpoint.',
|
|
code: 'UNKNOWN_OAUTH_ENDPOINT',
|
|
id: 'aa49e620-26cb-4e28-aad6-8cbcb58db147',
|
|
kind: 'client',
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
@bindThis
|
|
public async createTokenServer(fastify: FastifyInstance): Promise<void> {
|
|
registerFormBodyParser(fastify);
|
|
fastify.register(fastifyCors);
|
|
|
|
fastify.post('', async (request, reply) => {
|
|
applyNoStore(reply);
|
|
|
|
try {
|
|
const body = toRequestParameters(request.body);
|
|
const grantType = firstValue(body.grant_type);
|
|
if (!grantType) {
|
|
throw new InvalidRequest('grant_type is required');
|
|
}
|
|
if (grantType !== 'authorization_code') {
|
|
throw new UnsupportedGrantType();
|
|
}
|
|
|
|
const code = firstValue(body.code);
|
|
const clientId = firstValue(body.client_id);
|
|
const redirectUriValue = firstValue(body.redirect_uri);
|
|
const codeVerifier = firstValue(body.code_verifier);
|
|
|
|
this.#logger.info('Checking the received authorization code for the exchange');
|
|
if (!code) {
|
|
throw new InvalidGrant('grant request is invalid');
|
|
}
|
|
|
|
const granted = this.#grantCodeCache.get(code);
|
|
if (!granted) {
|
|
throw new InvalidGrant('grant request is invalid');
|
|
}
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2
|
|
// "If an authorization code is used more than once, the authorization server
|
|
// MUST deny the request and SHOULD revoke (when possible) all tokens
|
|
// previously issued based on that authorization code."
|
|
if (granted.used) {
|
|
await this.#revokeGrantCode(granted, code);
|
|
throw new InvalidGrant('grant request is invalid');
|
|
}
|
|
granted.used = true;
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3
|
|
if (clientId !== granted.clientId || redirectUriValue !== granted.redirectUri) {
|
|
throw new InvalidGrant('grant request is invalid');
|
|
}
|
|
|
|
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
|
|
if (!codeVerifier) {
|
|
throw new InvalidGrant('grant request is invalid');
|
|
}
|
|
checkPKCE(codeVerifier, granted.codeChallenge, 'S256');
|
|
|
|
// https://indieauth.spec.indieweb.org/#access-token-response
|
|
// The token response MUST include the canonical profile URL as `me`.
|
|
// Misskey uses the stable local actor URL so clients can later confirm
|
|
// that the returned profile URL declares the same authorization server.
|
|
const me = this.userEntityService.genLocalUserUri(granted.userId);
|
|
|
|
const accessToken = secureRndstr(128);
|
|
const now = new Date();
|
|
|
|
// NOTE: we don't have a setup for automatic token expiration
|
|
// https://indieauth.spec.indieweb.org/#access-token-response
|
|
// `expires_in` is only RECOMMENDED there, and RFC6749 Section 5.1 also
|
|
// allows omitting it when the server documents or otherwise defines the
|
|
// token lifetime. Misskey currently issues bearer tokens without a
|
|
// published expiration timestamp.
|
|
await this.accessTokensRepository.insert({
|
|
id: this.idService.gen(now.getTime()),
|
|
lastUsedAt: now,
|
|
userId: granted.userId,
|
|
token: accessToken,
|
|
hash: accessToken,
|
|
name: granted.clientId,
|
|
permission: granted.scopes,
|
|
});
|
|
|
|
if (granted.revoked) {
|
|
this.#logger.info('Canceling the token as the authorization code was revoked in parallel during the process.');
|
|
await this.accessTokensRepository.delete({ token: accessToken });
|
|
throw new InvalidGrant('grant request is invalid');
|
|
}
|
|
|
|
granted.grantedToken = accessToken;
|
|
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`);
|
|
|
|
reply.send({
|
|
access_token: accessToken,
|
|
token_type: 'Bearer',
|
|
scope: granted.scopes.join(' '),
|
|
me,
|
|
});
|
|
} catch (error) {
|
|
sendOAuthError(reply, normalizeOAuthError(error));
|
|
}
|
|
});
|
|
}
|
|
}
|