feat(backend): support OAuth 2.0 authorization (#11053)
* feat(backend): support OAuth 2.0 authorization * secureRndstr fix * nanndekowareta * nanndekowareta2 * nanndekowareta3 * unref? * refactor to not close fastify * use microformats-parser * Update OAuth2ProviderService.ts * clarify the reason behind dns lookup * refactor(backend): use @types/oauth2orize-pkce (#11350) * refactor(backend): use @types/oauth2orize-pkce * Update package.json * Update pnpm-lock.yaml --------- Co-authored-by: Kagami Sascha Rosylight <saschanaz@outlook.com> --------- Co-authored-by: mtgto <hogerappa@gmail.com> Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
This commit is contained in:
parent
c2370a1be6
commit
eb7b5f905a
12 changed files with 1739 additions and 36 deletions
|
@ -66,6 +66,7 @@
|
|||
"@fastify/accepts": "4.2.0",
|
||||
"@fastify/cookie": "8.3.0",
|
||||
"@fastify/cors": "8.3.0",
|
||||
"@fastify/express": "2.3.0",
|
||||
"@fastify/http-proxy": "9.2.1",
|
||||
"@fastify/multipart": "7.7.1",
|
||||
"@fastify/static": "6.10.2",
|
||||
|
@ -83,6 +84,7 @@
|
|||
"async-mutex": "^0.4.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.2",
|
||||
"bullmq": "4.4.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.0",
|
||||
|
@ -102,6 +104,7 @@
|
|||
"got": "13.0.0",
|
||||
"happy-dom": "10.0.3",
|
||||
"hpagent": "1.2.0",
|
||||
"http-link-header": "1.1.0",
|
||||
"ioredis": "5.3.2",
|
||||
"ip-cidr": "3.1.0",
|
||||
"ipaddr.js": "2.1.0",
|
||||
|
@ -113,6 +116,7 @@
|
|||
"jsrsasign": "10.8.6",
|
||||
"meilisearch": "0.33.0",
|
||||
"mfm-js": "0.23.3",
|
||||
"microformats-parser": "1.4.1",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
"ms": "3.0.0-canary.1",
|
||||
|
@ -121,10 +125,13 @@
|
|||
"nodemailer": "6.9.3",
|
||||
"nsfwjs": "2.4.2",
|
||||
"oauth": "0.10.0",
|
||||
"oauth2orize": "1.11.1",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.1.3",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.11.1",
|
||||
"pkce-challenge": "4.0.1",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
"pug": "3.0.2",
|
||||
|
@ -167,21 +174,25 @@
|
|||
"@types/accepts": "1.3.5",
|
||||
"@types/archiver": "5.3.2",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/body-parser": "1.19.2",
|
||||
"@types/cbor": "6.0.0",
|
||||
"@types/color-convert": "2.0.0",
|
||||
"@types/content-disposition": "0.5.5",
|
||||
"@types/fluent-ffmpeg": "2.1.21",
|
||||
"@types/http-link-header": "1.0.3",
|
||||
"@types/jest": "29.5.3",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/jsdom": "21.1.1",
|
||||
"@types/jsonld": "1.5.9",
|
||||
"@types/jsrsasign": "10.5.8",
|
||||
"@types/mime-types": "2.1.1",
|
||||
"@types/ms": "^0.7.31",
|
||||
"@types/ms": "0.7.31",
|
||||
"@types/node": "20.4.2",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.8",
|
||||
"@types/oauth": "0.9.1",
|
||||
"@types/oauth2orize": "1.11.0",
|
||||
"@types/oauth2orize-pkce": "0.1.0",
|
||||
"@types/pg": "8.10.2",
|
||||
"@types/pug": "2.0.6",
|
||||
"@types/punycode": "2.1.0",
|
||||
|
@ -192,6 +203,7 @@
|
|||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/semver": "7.5.0",
|
||||
"@types/sharp": "0.32.0",
|
||||
"@types/simple-oauth2": "5.0.4",
|
||||
"@types/sinonjs__fake-timers": "8.1.2",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/tmp": "0.2.3",
|
||||
|
@ -206,6 +218,7 @@
|
|||
"eslint-plugin-import": "2.27.5",
|
||||
"execa": "7.1.1",
|
||||
"jest": "29.6.1",
|
||||
"jest-mock": "29.6.1"
|
||||
"jest-mock": "29.6.1",
|
||||
"simple-oauth2": "5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js';
|
|||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { ClientLoggerService } from './web/ClientLoggerService.js';
|
||||
import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
@ -83,6 +84,7 @@ import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.
|
|||
ServerStatsChannelService,
|
||||
UserListChannelService,
|
||||
OpenApiServerService,
|
||||
OAuth2ProviderService,
|
||||
],
|
||||
exports: [
|
||||
ServerService,
|
||||
|
|
|
@ -30,6 +30,7 @@ import { WellKnownServerService } from './WellKnownServerService.js';
|
|||
import { FileServerService } from './FileServerService.js';
|
||||
import { ClientServerService } from './web/ClientServerService.js';
|
||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||
|
||||
const _dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
|
@ -63,12 +64,13 @@ export class ServerService implements OnApplicationShutdown {
|
|||
private clientServerService: ClientServerService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private loggerService: LoggerService,
|
||||
private oauth2ProviderService: OAuth2ProviderService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('server', 'gray', false);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async launch() {
|
||||
public async launch(): Promise<void> {
|
||||
const fastify = Fastify({
|
||||
trustProxy: true,
|
||||
logger: !['production', 'test'].includes(process.env.NODE_ENV ?? ''),
|
||||
|
@ -97,6 +99,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
fastify.register(this.activityPubServerService.createServer);
|
||||
fastify.register(this.nodeinfoServerService.createServer);
|
||||
fastify.register(this.wellKnownServerService.createServer);
|
||||
fastify.register(this.oauth2ProviderService.createServer);
|
||||
|
||||
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
|
||||
const path = request.params.path;
|
||||
|
|
482
packages/backend/src/server/oauth/OAuth2ProviderService.ts
Normal file
482
packages/backend/src/server/oauth/OAuth2ProviderService.ts
Normal file
|
@ -0,0 +1,482 @@
|
|||
import dns from 'node:dns/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import httpLinkHeader from 'http-link-header';
|
||||
import ipaddr from 'ipaddr.js';
|
||||
import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize';
|
||||
import oauth2Pkce from 'oauth2orize-pkce';
|
||||
import fastifyView from '@fastify/view';
|
||||
import pug from 'pug';
|
||||
import bodyParser from 'body-parser';
|
||||
import fastifyExpress from '@fastify/express';
|
||||
import { verifyChallenge } from 'pkce-challenge';
|
||||
import { mf2 } from 'microformats-parser';
|
||||
import { secureRndstr } from '@/misc/secure-rndstr.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { kinds } from '@/misc/api-permissions.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type { AccessTokensRepository, UsersRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { LocalUser } from '@/models/entities/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 type { ServerResponse } from 'node:http';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
// 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 AuthorizationError('client_id must be a valid URL', 'invalid_request'); }
|
||||
})();
|
||||
|
||||
// "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 AuthorizationError('client_id must be a valid HTTPS URL', 'invalid_request');
|
||||
}
|
||||
|
||||
// "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 AuthorizationError('client_id must not contain dot path segments', 'invalid_request');
|
||||
}
|
||||
|
||||
// ("MAY contain a query string component")
|
||||
|
||||
// "MUST NOT contain a fragment component"
|
||||
if (url.hash) {
|
||||
throw new AuthorizationError('client_id must not contain a fragment component', 'invalid_request');
|
||||
}
|
||||
|
||||
// "MUST NOT contain a username or password component"
|
||||
if (url.username || url.password) {
|
||||
throw new AuthorizationError('client_id must not contain a username or a password', 'invalid_request');
|
||||
}
|
||||
|
||||
// ("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 AuthorizationError('client_id must have a domain name as a host name', 'invalid_request');
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
interface ClientInformation {
|
||||
id: string;
|
||||
redirectUris: string[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#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."
|
||||
// (But we don't display any icon for now)
|
||||
// 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."
|
||||
async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise<ClientInformation> {
|
||||
try {
|
||||
const res = await httpRequestService.send(id);
|
||||
const redirectUris: string[] = [];
|
||||
|
||||
const linkHeader = res.headers.get('link');
|
||||
if (linkHeader) {
|
||||
redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri));
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const fragment = JSDOM.fragment(text);
|
||||
|
||||
redirectUris.push(...[...fragment.querySelectorAll<HTMLLinkElement>('link[rel=redirect_uri][href]')].map(el => el.href));
|
||||
|
||||
let name = id;
|
||||
if (text) {
|
||||
const microformats = mf2(text, { baseUrl: res.url });
|
||||
const nameProperty = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url?.includes(id))?.properties.name[0];
|
||||
if (typeof nameProperty === 'string') {
|
||||
name = nameProperty;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()),
|
||||
name: typeof name === 'string' ? name : id,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
logger.error('Error while fetching client information', { err });
|
||||
if (err instanceof StatusError) {
|
||||
throw new AuthorizationError('Failed to fetch client information', 'invalid_request');
|
||||
} else {
|
||||
throw new AuthorizationError('Failed to parse client information', 'server_error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OmitFirstElement<T extends unknown[]> = T extends [unknown, ...(infer R)]
|
||||
? R
|
||||
: [];
|
||||
|
||||
interface OAuthParsedRequest extends OAuth2Req {
|
||||
codeChallenge: string;
|
||||
codeChallengeMethod: string;
|
||||
}
|
||||
|
||||
interface OAuthHttpResponse extends ServerResponse {
|
||||
redirect(location: string): void;
|
||||
}
|
||||
|
||||
interface OAuth2DecisionRequest extends MiddlewareRequest {
|
||||
body: {
|
||||
transaction_id: string;
|
||||
cancel: boolean;
|
||||
login_token: string;
|
||||
}
|
||||
}
|
||||
|
||||
function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] {
|
||||
return {
|
||||
query: (txn, res, params): void => {
|
||||
// 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."
|
||||
params.iss = issuerUrl;
|
||||
|
||||
const parsed = new URL(txn.redirectURI);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
parsed.searchParams.append(key, value as string);
|
||||
}
|
||||
|
||||
return (res as OAuthHttpResponse).redirect(parsed.toString());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the transaction ID and the oauth/authorize parameters.
|
||||
*
|
||||
* Flow:
|
||||
* 1. oauth/authorize endpoint will call store() to store the parameters
|
||||
* and puts the generated transaction ID to the dialog page
|
||||
* 2. oauth/decision will call load() to retrieve the parameters and then remove()
|
||||
*/
|
||||
class OAuth2Store {
|
||||
#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // expires after 5min
|
||||
|
||||
load(req: OAuth2DecisionRequest, cb: (err: Error | null, txn?: OAuth2) => void): void {
|
||||
const { transaction_id } = req.body;
|
||||
if (!transaction_id) {
|
||||
cb(new AuthorizationError('Missing transaction ID', 'invalid_request'));
|
||||
return;
|
||||
}
|
||||
const loaded = this.#cache.get(transaction_id);
|
||||
if (!loaded) {
|
||||
cb(new AuthorizationError('Invalid or expired transaction ID', 'access_denied'));
|
||||
return;
|
||||
}
|
||||
cb(null, loaded);
|
||||
}
|
||||
|
||||
store(req: OAuth2DecisionRequest, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void {
|
||||
const transactionId = secureRndstr(128);
|
||||
this.#cache.set(transactionId, oauth2);
|
||||
cb(null, transactionId);
|
||||
}
|
||||
|
||||
remove(req: OAuth2DecisionRequest, tid: string, cb: () => void): void {
|
||||
this.#cache.delete(tid);
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuth2ProviderService {
|
||||
#server = oauth2orize.createServer({
|
||||
store: new OAuth2Store(),
|
||||
});
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
private httpRequestService: HttpRequestService,
|
||||
@Inject(DI.accessTokensRepository)
|
||||
accessTokensRepository: AccessTokensRepository,
|
||||
idService: IdService,
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
private cacheService: CacheService,
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.#logger = loggerService.getLogger('oauth');
|
||||
|
||||
const grantCodeCache = new MemoryKVCache<{
|
||||
clientId: string,
|
||||
userId: string,
|
||||
redirectUri: string,
|
||||
codeChallenge: string,
|
||||
scopes: string[],
|
||||
|
||||
// fields to prevent multiple code use
|
||||
grantedToken?: string,
|
||||
revoked?: boolean,
|
||||
used?: boolean,
|
||||
}>(1000 * 60 * 5); // expires after 5m
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics
|
||||
// "Authorization servers MUST support PKCE [RFC7636]."
|
||||
this.#server.grant(oauth2Pkce.extensions());
|
||||
this.#server.grant(oauth2orize.grant.code({
|
||||
modes: getQueryMode(config.url),
|
||||
}, (client, redirectUri, token, ares, areq, locals, done) => {
|
||||
(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => {
|
||||
this.#logger.info(`Checking the user before sending authorization code to ${client.id}`);
|
||||
|
||||
if (!token) {
|
||||
throw new AuthorizationError('No user', 'invalid_request');
|
||||
}
|
||||
const user = await this.cacheService.localUserByNativeTokenCache.fetch(token,
|
||||
() => this.usersRepository.findOneBy({ token }) as Promise<LocalUser | null>);
|
||||
if (!user) {
|
||||
throw new AuthorizationError('No such user', 'invalid_request');
|
||||
}
|
||||
|
||||
this.#logger.info(`Sending authorization code on behalf of user ${user.id} to ${client.id} through ${redirectUri}, with scope: [${areq.scope}]`);
|
||||
|
||||
const code = secureRndstr(128);
|
||||
grantCodeCache.set(code, {
|
||||
clientId: client.id,
|
||||
userId: user.id,
|
||||
redirectUri,
|
||||
codeChallenge: (areq as OAuthParsedRequest).codeChallenge,
|
||||
scopes: areq.scope,
|
||||
});
|
||||
return [code];
|
||||
})().then(args => done(null, ...args), err => done(err));
|
||||
}));
|
||||
this.#server.exchange(oauth2orize.exchange.authorizationCode((client, code, redirectUri, body, authInfo, done) => {
|
||||
(async (): Promise<OmitFirstElement<Parameters<typeof done>> | undefined> => {
|
||||
this.#logger.info('Checking the received authorization code for the exchange');
|
||||
const granted = grantCodeCache.get(code);
|
||||
if (!granted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.#logger.info(`Detected multiple code use from ${granted.clientId} for user ${granted.userId}. Revoking the code.`);
|
||||
grantCodeCache.delete(code);
|
||||
granted.revoked = true;
|
||||
if (granted.grantedToken) {
|
||||
await accessTokensRepository.delete({ token: granted.grantedToken });
|
||||
}
|
||||
return;
|
||||
}
|
||||
granted.used = true;
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.3
|
||||
if (body.client_id !== granted.clientId) return;
|
||||
if (redirectUri !== granted.redirectUri) return;
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.6
|
||||
if (!body.code_verifier) return;
|
||||
if (!(await verifyChallenge(body.code_verifier as string, granted.codeChallenge))) return;
|
||||
|
||||
const accessToken = secureRndstr(128);
|
||||
const now = new Date();
|
||||
|
||||
// NOTE: we don't have a setup for automatic token expiration
|
||||
await accessTokensRepository.insert({
|
||||
id: idService.genId(),
|
||||
createdAt: now,
|
||||
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 accessTokensRepository.delete({ token: accessToken });
|
||||
return;
|
||||
}
|
||||
|
||||
granted.grantedToken = accessToken;
|
||||
this.#logger.info(`Generated access token for ${granted.clientId} for user ${granted.userId}, with scope: [${granted.scopes}]`);
|
||||
|
||||
return [accessToken, undefined, { scope: granted.scopes.join(' ') }];
|
||||
})().then(args => done(null, ...args ?? []), err => done(err));
|
||||
}));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async createServer(fastify: FastifyInstance): Promise<void> {
|
||||
// https://datatracker.ietf.org/doc/html/rfc8414.html
|
||||
// https://indieauth.spec.indieweb.org/#indieauth-server-metadata
|
||||
fastify.get('/.well-known/oauth-authorization-server', async (_request, reply) => {
|
||||
reply.send({
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get('/oauth/authorize', async (request, reply) => {
|
||||
const oauth2 = (request.raw as MiddlewareRequest).oauth2;
|
||||
if (!oauth2) {
|
||||
throw new Error('Unexpected lack of authorization information');
|
||||
}
|
||||
|
||||
this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`);
|
||||
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
return await reply.view('oauth', {
|
||||
transactionId: oauth2.transactionID,
|
||||
clientName: oauth2.client.name,
|
||||
scope: oauth2.req.scope.join(' '),
|
||||
});
|
||||
});
|
||||
fastify.post('/oauth/decision', async () => { });
|
||||
fastify.post('/oauth/token', async () => { });
|
||||
|
||||
fastify.register(fastifyView, {
|
||||
root: fileURLToPath(new URL('../web/views', import.meta.url)),
|
||||
engine: { pug },
|
||||
defaultContext: {
|
||||
version: this.config.version,
|
||||
config: this.config,
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.register(fastifyExpress);
|
||||
fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => {
|
||||
(async (): Promise<Parameters<typeof done>> => {
|
||||
// This should return client/redirectURI AND the error, or
|
||||
// the handler can't send error to the redirection URI
|
||||
|
||||
const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope } = areq as OAuthParsedRequest;
|
||||
|
||||
this.#logger.info(`Validating authorization parameters, with client_id: ${clientID}, redirect_uri: ${redirectURI}, scope: ${scope}`);
|
||||
|
||||
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 AuthorizationError('client_id resolves to disallowed IP range.', 'invalid_request');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (!clientInfo.redirectUris.includes(redirectURI)) {
|
||||
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
|
||||
}
|
||||
|
||||
try {
|
||||
const scopes = [...new Set(scope)].filter(s => kinds.includes(s));
|
||||
if (!scopes.length) {
|
||||
throw new AuthorizationError('`scope` parameter has no known scope', 'invalid_scope');
|
||||
}
|
||||
areq.scope = scopes;
|
||||
|
||||
// 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 codeChallenge !== 'string') {
|
||||
throw new AuthorizationError('`code_challenge` parameter is required', 'invalid_request');
|
||||
}
|
||||
if (codeChallengeMethod !== 'S256') {
|
||||
throw new AuthorizationError('`code_challenge_method` parameter must be set as S256', 'invalid_request');
|
||||
}
|
||||
} catch (err) {
|
||||
return [err as Error, clientInfo, redirectURI];
|
||||
}
|
||||
|
||||
return [null, clientInfo, redirectURI];
|
||||
})().then(args => done(...args), err => done(err));
|
||||
}) as ValidateFunctionArity2));
|
||||
fastify.use('/oauth/authorize', this.#server.errorHandler({
|
||||
mode: 'indirect',
|
||||
modes: getQueryMode(this.config.url),
|
||||
}));
|
||||
fastify.use('/oauth/authorize', this.#server.errorHandler());
|
||||
|
||||
fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false }));
|
||||
fastify.use('/oauth/decision', this.#server.decision((req, done) => {
|
||||
const { body } = req as OAuth2DecisionRequest;
|
||||
this.#logger.info(`Received the decision. Cancel: ${!!body.cancel}`);
|
||||
req.user = body.login_token;
|
||||
done(null, undefined);
|
||||
}));
|
||||
fastify.use('/oauth/decision', this.#server.errorHandler());
|
||||
|
||||
// Clients may use JSON or urlencoded
|
||||
fastify.use('/oauth/token', bodyParser.urlencoded({ extended: false }));
|
||||
fastify.use('/oauth/token', bodyParser.json({ strict: true }));
|
||||
fastify.use('/oauth/token', this.#server.token());
|
||||
fastify.use('/oauth/token', this.#server.errorHandler());
|
||||
|
||||
// Return 404 for any unknown paths under /oauth so that clients can know
|
||||
// whether a certain endpoint is supported or not.
|
||||
fastify.all('/oauth/*', 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
9
packages/backend/src/server/web/views/oauth.pug
Normal file
9
packages/backend/src/server/web/views/oauth.pug
Normal file
|
@ -0,0 +1,9 @@
|
|||
extends ./base
|
||||
|
||||
block meta
|
||||
//- Should be removed by the page when it loads, so that it won't needlessly
|
||||
//- stay when user navigates away via the navigation bar
|
||||
//- XXX: Remove navigation bar in auth page?
|
||||
meta(name='misskey:oauth:transaction-id' content=transactionId)
|
||||
meta(name='misskey:oauth:client-name' content=clientName)
|
||||
meta(name='misskey:oauth:scope' content=scope)
|
|
@ -7,7 +7,7 @@ process.env.NODE_ENV = 'test';
|
|||
|
||||
import * as assert from 'assert';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream } from '../utils.js';
|
||||
import { signup, api, startServer, successfulApiCall, failedApiCall, uploadFile, waitFire, connectStream, relativeFetch } from '../utils.js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
|
@ -223,6 +223,42 @@ describe('API', () => {
|
|||
assert.ok(result.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="invalid_request", error_description'));
|
||||
});
|
||||
|
||||
// TODO: insufficient_scope test (authテストが全然なくて書けない)
|
||||
describe('invalid bearer format', () => {
|
||||
test('No preceding bearer', async () => {
|
||||
const result = await relativeFetch('api/notes/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: alice.token,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: 'test' }),
|
||||
});
|
||||
assert.strictEqual(result.status, 401);
|
||||
});
|
||||
|
||||
test('Lowercase bearer', async () => {
|
||||
const result = await relativeFetch('api/notes/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `bearer ${alice.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: 'test' }),
|
||||
});
|
||||
assert.strictEqual(result.status, 401);
|
||||
});
|
||||
|
||||
test('No space after bearer', async () => {
|
||||
const result = await relativeFetch('api/notes/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer${alice.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: 'test' }),
|
||||
});
|
||||
assert.strictEqual(result.status, 401);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
939
packages/backend/test/e2e/oauth.ts
Normal file
939
packages/backend/test/e2e/oauth.ts
Normal file
|
@ -0,0 +1,939 @@
|
|||
/**
|
||||
* Basic OAuth tests to make sure the library is correctly integrated to Misskey
|
||||
* and not regressed by version updates or potential migration to another library.
|
||||
*/
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { AuthorizationCode, ResourceOwnerPassword, type AuthorizationTokenConfig, ClientCredentials, ModuleOptions } from 'simple-oauth2';
|
||||
import pkceChallenge from 'pkce-challenge';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import Fastify, { type FastifyReply, type FastifyInstance } from 'fastify';
|
||||
import { api, port, signup, startServer } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
import type { INestApplicationContext } from '@nestjs/common';
|
||||
|
||||
const host = `http://127.0.0.1:${port}`;
|
||||
|
||||
const clientPort = port + 1;
|
||||
const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`;
|
||||
|
||||
const basicAuthParams: AuthorizationParamsExtended = {
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
};
|
||||
|
||||
interface AuthorizationParamsExtended {
|
||||
redirect_uri: string;
|
||||
scope: string | string[];
|
||||
state: string;
|
||||
code_challenge?: string;
|
||||
code_challenge_method?: string;
|
||||
}
|
||||
|
||||
interface AuthorizationTokenConfigExtended extends AuthorizationTokenConfig {
|
||||
code_verifier: string | undefined;
|
||||
}
|
||||
|
||||
interface GetTokenError {
|
||||
data: {
|
||||
payload: {
|
||||
error: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clientConfig: ModuleOptions<'client_id'> = {
|
||||
client: {
|
||||
id: `http://127.0.0.1:${clientPort}/`,
|
||||
secret: '',
|
||||
},
|
||||
auth: {
|
||||
tokenHost: host,
|
||||
tokenPath: '/oauth/token',
|
||||
authorizePath: '/oauth/authorize',
|
||||
},
|
||||
options: {
|
||||
authorizationMethod: 'body',
|
||||
},
|
||||
};
|
||||
|
||||
function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } {
|
||||
const fragment = JSDOM.fragment(html);
|
||||
return {
|
||||
transactionId: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]')?.content,
|
||||
clientName: fragment.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content,
|
||||
};
|
||||
}
|
||||
|
||||
function fetchDecision(transactionId: string, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
|
||||
return fetch(new URL('/oauth/decision', host), {
|
||||
method: 'post',
|
||||
body: new URLSearchParams({
|
||||
transaction_id: transactionId,
|
||||
login_token: user.token,
|
||||
cancel: cancel ? 'cancel' : '',
|
||||
}),
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchDecisionFromResponse(response: Response, user: misskey.entities.MeSignup, { cancel }: { cancel?: boolean } = {}): Promise<Response> {
|
||||
const { transactionId } = getMeta(await response.text());
|
||||
assert.ok(transactionId);
|
||||
|
||||
return await fetchDecision(transactionId, user, { cancel });
|
||||
}
|
||||
|
||||
async function fetchAuthorizationCode(user: misskey.entities.MeSignup, scope: string, code_challenge: string): Promise<{ client: AuthorizationCode, code: string }> {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope,
|
||||
state: 'state',
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
|
||||
const decisionResponse = await fetchDecisionFromResponse(response, user);
|
||||
assert.strictEqual(decisionResponse.status, 302);
|
||||
|
||||
const locationHeader = decisionResponse.headers.get('location');
|
||||
assert.ok(locationHeader);
|
||||
|
||||
const location = new URL(locationHeader);
|
||||
assert.ok(location.searchParams.has('code'));
|
||||
|
||||
const code = new URL(location).searchParams.get('code');
|
||||
assert.ok(code);
|
||||
|
||||
return { client, code };
|
||||
}
|
||||
|
||||
function assertIndirectError(response: Response, error: string): void {
|
||||
assert.strictEqual(response.status, 302);
|
||||
|
||||
const locationHeader = response.headers.get('location');
|
||||
assert.ok(locationHeader);
|
||||
|
||||
const location = new URL(locationHeader);
|
||||
assert.strictEqual(location.searchParams.get('error'), error);
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
|
||||
assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local');
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1
|
||||
assert.ok(location.searchParams.has('state'));
|
||||
}
|
||||
|
||||
async function assertDirectError(response: Response, status: number, error: string): Promise<void> {
|
||||
assert.strictEqual(response.status, status);
|
||||
|
||||
const data = await response.json();
|
||||
assert.strictEqual(data.error, error);
|
||||
}
|
||||
|
||||
describe('OAuth', () => {
|
||||
let app: INestApplicationContext;
|
||||
let fastify: FastifyInstance;
|
||||
|
||||
let alice: misskey.entities.MeSignup;
|
||||
let bob: misskey.entities.MeSignup;
|
||||
|
||||
let sender: (reply: FastifyReply) => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await startServer();
|
||||
alice = await signup({ username: 'alice' });
|
||||
bob = await signup({ username: 'bob' });
|
||||
|
||||
fastify = Fastify();
|
||||
fastify.get('/', async (request, reply) => {
|
||||
sender(reply);
|
||||
});
|
||||
await fastify.listen({ port: clientPort });
|
||||
}, 1000 * 60 * 2);
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.MISSKEY_TEST_CHECK_IP_RANGE = '';
|
||||
sender = (reply): void => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fastify.close();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('Full flow', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
|
||||
const meta = getMeta(await response.text());
|
||||
assert.strictEqual(typeof meta.transactionId, 'string');
|
||||
assert.ok(meta.transactionId);
|
||||
assert.strictEqual(meta.clientName, 'Misklient');
|
||||
|
||||
const decisionResponse = await fetchDecision(meta.transactionId, alice);
|
||||
assert.strictEqual(decisionResponse.status, 302);
|
||||
assert.ok(decisionResponse.headers.has('location'));
|
||||
|
||||
const locationHeader = decisionResponse.headers.get('location');
|
||||
assert.ok(locationHeader);
|
||||
|
||||
const location = new URL(locationHeader);
|
||||
assert.strictEqual(location.origin + location.pathname, redirect_uri);
|
||||
assert.ok(location.searchParams.has('code'));
|
||||
assert.strictEqual(location.searchParams.get('state'), 'state');
|
||||
// https://datatracker.ietf.org/doc/html/rfc9207#name-response-parameter-iss
|
||||
assert.strictEqual(location.searchParams.get('iss'), 'http://misskey.local');
|
||||
|
||||
const code = new URL(location).searchParams.get('code');
|
||||
assert.ok(code);
|
||||
|
||||
const token = await client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended);
|
||||
assert.strictEqual(typeof token.token.access_token, 'string');
|
||||
assert.strictEqual(token.token.token_type, 'Bearer');
|
||||
assert.strictEqual(token.token.scope, 'write:notes');
|
||||
|
||||
const createResult = await api('notes/create', { text: 'test' }, {
|
||||
token: token.token.access_token as string,
|
||||
bearer: true,
|
||||
});
|
||||
assert.strictEqual(createResult.status, 200);
|
||||
|
||||
const createResultBody = createResult.body as misskey.Endpoints['notes/create']['res'];
|
||||
assert.strictEqual(createResultBody.createdNote.text, 'test');
|
||||
});
|
||||
|
||||
test('Two concurrent flows', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const pkceAlice = await pkceChallenge(128);
|
||||
const pkceBob = await pkceChallenge(128);
|
||||
|
||||
const responseAlice = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: pkceAlice.code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(responseAlice.status, 200);
|
||||
|
||||
const responseBob = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: pkceBob.code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(responseBob.status, 200);
|
||||
|
||||
const decisionResponseAlice = await fetchDecisionFromResponse(responseAlice, alice);
|
||||
assert.strictEqual(decisionResponseAlice.status, 302);
|
||||
|
||||
const decisionResponseBob = await fetchDecisionFromResponse(responseBob, bob);
|
||||
assert.strictEqual(decisionResponseBob.status, 302);
|
||||
|
||||
const locationHeaderAlice = decisionResponseAlice.headers.get('location');
|
||||
assert.ok(locationHeaderAlice);
|
||||
const locationAlice = new URL(locationHeaderAlice);
|
||||
|
||||
const locationHeaderBob = decisionResponseBob.headers.get('location');
|
||||
assert.ok(locationHeaderBob);
|
||||
const locationBob = new URL(locationHeaderBob);
|
||||
|
||||
const codeAlice = locationAlice.searchParams.get('code');
|
||||
assert.ok(codeAlice);
|
||||
const codeBob = locationBob.searchParams.get('code');
|
||||
assert.ok(codeBob);
|
||||
|
||||
const tokenAlice = await client.getToken({
|
||||
code: codeAlice,
|
||||
redirect_uri,
|
||||
code_verifier: pkceAlice.code_verifier,
|
||||
} as AuthorizationTokenConfigExtended);
|
||||
|
||||
const tokenBob = await client.getToken({
|
||||
code: codeBob,
|
||||
redirect_uri,
|
||||
code_verifier: pkceBob.code_verifier,
|
||||
} as AuthorizationTokenConfigExtended);
|
||||
|
||||
const createResultAlice = await api('notes/create', { text: 'test' }, {
|
||||
token: tokenAlice.token.access_token as string,
|
||||
bearer: true,
|
||||
});
|
||||
assert.strictEqual(createResultAlice.status, 200);
|
||||
|
||||
const createResultBob = await api('notes/create', { text: 'test' }, {
|
||||
token: tokenBob.token.access_token as string,
|
||||
bearer: true,
|
||||
});
|
||||
assert.strictEqual(createResultAlice.status, 200);
|
||||
|
||||
const createResultBodyAlice = await createResultAlice.body as misskey.Endpoints['notes/create']['res'];
|
||||
assert.strictEqual(createResultBodyAlice.createdNote.user.username, 'alice');
|
||||
|
||||
const createResultBodyBob = await createResultBob.body as misskey.Endpoints['notes/create']['res'];
|
||||
assert.strictEqual(createResultBodyBob.createdNote.user.username, 'bob');
|
||||
});
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc7636.html
|
||||
describe('PKCE', () => {
|
||||
// https://datatracker.ietf.org/doc/html/rfc7636.html#section-4.4.1
|
||||
// '... the authorization endpoint MUST return the authorization
|
||||
// error response with the "error" value set to "invalid_request".'
|
||||
test('Require PKCE', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
// Pattern 1: No PKCE fields at all
|
||||
let response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
}), { redirect: 'manual' });
|
||||
assertIndirectError(response, 'invalid_request');
|
||||
|
||||
// Pattern 2: Only code_challenge
|
||||
response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||
assertIndirectError(response, 'invalid_request');
|
||||
|
||||
// Pattern 3: Only code_challenge_method
|
||||
response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||
assertIndirectError(response, 'invalid_request');
|
||||
|
||||
// Pattern 4: Unsupported code_challenge_method
|
||||
response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'SSSS',
|
||||
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||
assertIndirectError(response, 'invalid_request');
|
||||
});
|
||||
|
||||
// Use precomputed challenge/verifier set here for deterministic test
|
||||
const code_challenge = '4w2GDuvaxXlw2l46k5PFIoIcTGHdzw2i3hrn-C_Q6f7u0-nTYKd-beVEYy9XinYsGtAix.Nnvr.GByD3lAii2ibPRsSDrZgIN0YQb.kfevcfR9aDKoTLyOUm4hW4ABhs';
|
||||
const code_verifier = 'Ew8VSBiH59JirLlg7ocFpLQ6NXuFC1W_rn8gmRzBKc8';
|
||||
|
||||
const tests: Record<string, string | undefined> = {
|
||||
'Code followed by some junk code': code_verifier + 'x',
|
||||
'Clipped code': code_verifier.slice(0, 80),
|
||||
'Some part of code is replaced': code_verifier.slice(0, -10) + 'x'.repeat(10),
|
||||
'No verifier': undefined,
|
||||
};
|
||||
|
||||
describe('Verify PKCE', () => {
|
||||
for (const [title, wrong_verifier] of Object.entries(tests)) {
|
||||
test(title, async () => {
|
||||
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
await assert.rejects(client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier: wrong_verifier,
|
||||
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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."
|
||||
describe('Revoking authorization code', () => {
|
||||
test('On success', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
await client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended);
|
||||
|
||||
await assert.rejects(client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('On failure', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
await assert.rejects(client.getToken({ code, redirect_uri }), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||
return true;
|
||||
});
|
||||
|
||||
await assert.rejects(client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Revoke the already granted access token', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
const token = await client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended);
|
||||
|
||||
const createResult = await api('notes/create', { text: 'test' }, {
|
||||
token: token.token.access_token as string,
|
||||
bearer: true,
|
||||
});
|
||||
assert.strictEqual(createResult.status, 200);
|
||||
|
||||
await assert.rejects(client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||
return true;
|
||||
});
|
||||
|
||||
const createResult2 = await api('notes/create', { text: 'test' }, {
|
||||
token: token.token.access_token as string,
|
||||
bearer: true,
|
||||
});
|
||||
assert.strictEqual(createResult2.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
test('Cancellation', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
|
||||
const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true });
|
||||
assert.strictEqual(decisionResponse.status, 302);
|
||||
|
||||
const locationHeader = decisionResponse.headers.get('location');
|
||||
assert.ok(locationHeader);
|
||||
|
||||
const location = new URL(locationHeader);
|
||||
assert.ok(!location.searchParams.has('code'));
|
||||
assert.ok(location.searchParams.has('error'));
|
||||
});
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.3
|
||||
describe('Scope', () => {
|
||||
// "If the client omits the scope parameter when requesting
|
||||
// authorization, the authorization server MUST either process the
|
||||
// request using a pre-defined default value or fail the request
|
||||
// indicating an invalid scope."
|
||||
// (And Misskey does the latter)
|
||||
test('Missing scope', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||
assertIndirectError(response, 'invalid_scope');
|
||||
});
|
||||
|
||||
test('Empty scope', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: '',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||
assertIndirectError(response, 'invalid_scope');
|
||||
});
|
||||
|
||||
test('Unknown scopes', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'test:unknown test:unknown2',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||
assertIndirectError(response, 'invalid_scope');
|
||||
});
|
||||
|
||||
// "If the issued access token scope
|
||||
// is different from the one requested by the client, the authorization
|
||||
// server MUST include the "scope" response parameter to inform the
|
||||
// client of the actual scope granted."
|
||||
// (Although Misskey always return scope, which is also fine)
|
||||
test('Partially known scopes', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
|
||||
// Just get the known scope for this case for backward compatibility
|
||||
const { client, code } = await fetchAuthorizationCode(
|
||||
alice,
|
||||
'write:notes test:unknown test:unknown2',
|
||||
code_challenge,
|
||||
);
|
||||
|
||||
const token = await client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended);
|
||||
|
||||
assert.strictEqual(token.token.scope, 'write:notes');
|
||||
});
|
||||
|
||||
test('Known scopes', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes read:account',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
|
||||
assert.strictEqual(response.status, 200);
|
||||
});
|
||||
|
||||
test('Duplicated scopes', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
|
||||
const { client, code } = await fetchAuthorizationCode(
|
||||
alice,
|
||||
'write:notes write:notes read:account read:account',
|
||||
code_challenge,
|
||||
);
|
||||
|
||||
const token = await client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended);
|
||||
assert.strictEqual(token.token.scope, 'write:notes read:account');
|
||||
});
|
||||
|
||||
test('Scope check by API', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
|
||||
const { client, code } = await fetchAuthorizationCode(alice, 'read:account', code_challenge);
|
||||
|
||||
const token = await client.getToken({
|
||||
code,
|
||||
redirect_uri,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended);
|
||||
assert.strictEqual(typeof token.token.access_token, 'string');
|
||||
|
||||
const createResult = await api('notes/create', { text: 'test' }, {
|
||||
token: token.token.access_token as string,
|
||||
bearer: true,
|
||||
});
|
||||
assert.strictEqual(createResult.status, 403);
|
||||
assert.ok(createResult.headers.get('WWW-Authenticate')?.startsWith('Bearer realm="Misskey", error="insufficient_scope", error_description'));
|
||||
});
|
||||
});
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-3.1.2.4
|
||||
// "If an authorization request fails validation due to a missing,
|
||||
// invalid, or mismatching redirection URI, the authorization server
|
||||
// SHOULD inform the resource owner of the error and MUST NOT
|
||||
// automatically redirect the user-agent to the invalid redirection URI."
|
||||
describe('Redirection', () => {
|
||||
test('Invalid redirect_uri at authorization endpoint', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri: 'http://127.0.0.2/',
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Invalid redirect_uri including the valid one at authorization endpoint', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri: 'http://127.0.0.1/redirection',
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('No redirect_uri at authorization endpoint', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Invalid redirect_uri at token endpoint', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
|
||||
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
await assert.rejects(client.getToken({
|
||||
code,
|
||||
redirect_uri: 'http://127.0.0.2/',
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Invalid redirect_uri including the valid one at token endpoint', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
|
||||
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
await assert.rejects(client.getToken({
|
||||
code,
|
||||
redirect_uri: 'http://127.0.0.1/redirection',
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('No redirect_uri at token endpoint', async () => {
|
||||
const { code_challenge, code_verifier } = await pkceChallenge(128);
|
||||
|
||||
const { client, code } = await fetchAuthorizationCode(alice, 'write:notes', code_challenge);
|
||||
|
||||
await assert.rejects(client.getToken({
|
||||
code,
|
||||
code_verifier,
|
||||
} as AuthorizationTokenConfigExtended), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'invalid_grant');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc8414
|
||||
test('Server metadata', async () => {
|
||||
const response = await fetch(new URL('.well-known/oauth-authorization-server', host));
|
||||
assert.strictEqual(response.status, 200);
|
||||
|
||||
const body = await response.json();
|
||||
assert.strictEqual(body.issuer, 'http://misskey.local');
|
||||
assert.ok(body.scopes_supported.includes('write:notes'));
|
||||
});
|
||||
|
||||
// Any error on decision endpoint is solely on Misskey side and nothing to do with the client.
|
||||
// Do not use indirect error here.
|
||||
describe('Decision endpoint', () => {
|
||||
test('No login token', async () => {
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL(basicAuthParams));
|
||||
assert.strictEqual(response.status, 200);
|
||||
|
||||
const { transactionId } = getMeta(await response.text());
|
||||
assert.ok(transactionId);
|
||||
|
||||
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
|
||||
method: 'post',
|
||||
body: new URLSearchParams({
|
||||
transaction_id: transactionId,
|
||||
}),
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
await assertDirectError(decisionResponse, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('No transaction ID', async () => {
|
||||
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
|
||||
method: 'post',
|
||||
body: new URLSearchParams({
|
||||
login_token: alice.token,
|
||||
}),
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
await assertDirectError(decisionResponse, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Invalid transaction ID', async () => {
|
||||
const decisionResponse = await fetch(new URL('/oauth/decision', host), {
|
||||
method: 'post',
|
||||
body: new URLSearchParams({
|
||||
login_token: alice.token,
|
||||
transaction_id: 'invalid_id',
|
||||
}),
|
||||
redirect: 'manual',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
await assertDirectError(decisionResponse, 403, 'access_denied');
|
||||
});
|
||||
});
|
||||
|
||||
// Only authorization code grant is supported
|
||||
describe('Grant type', () => {
|
||||
test('Implicit grant is not supported', async () => {
|
||||
const url = new URL('/oauth/authorize', host);
|
||||
url.searchParams.append('response_type', 'token');
|
||||
const response = await fetch(url);
|
||||
assertDirectError(response, 501, 'unsupported_response_type');
|
||||
});
|
||||
|
||||
test('Resource owner grant is not supported', async () => {
|
||||
const client = new ResourceOwnerPassword({
|
||||
...clientConfig,
|
||||
auth: {
|
||||
tokenHost: host,
|
||||
tokenPath: '/oauth/token',
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.getToken({
|
||||
username: 'alice',
|
||||
password: 'test',
|
||||
}), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'unsupported_grant_type');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('Client credential grant is not supported', async () => {
|
||||
const client = new ClientCredentials({
|
||||
...clientConfig,
|
||||
auth: {
|
||||
tokenHost: host,
|
||||
tokenPath: '/oauth/token',
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(client.getToken({}), (err: GetTokenError) => {
|
||||
assert.strictEqual(err.data.payload.error, 'unsupported_grant_type');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// https://indieauth.spec.indieweb.org/#client-information-discovery
|
||||
describe('Client Information Discovery', () => {
|
||||
describe('Redirection', () => {
|
||||
const tests: Record<string, (reply: FastifyReply) => void> = {
|
||||
'Read HTTP header': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Mixed links': reply => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in Link header': reply => {
|
||||
reply.header('Link', '</redirect2>; rel="redirect_uri",</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
'Multiple items in HTML': reply => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<link rel="redirect_uri" href="/redirect2" />
|
||||
<link rel="redirect_uri" href="/redirect" />
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
||||
for (const [title, replyFunc] of Object.entries(tests)) {
|
||||
test(title, async () => {
|
||||
sender = replyFunc;
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
});
|
||||
}
|
||||
|
||||
test('No item', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/" class="u-url p-name">Misklient
|
||||
`);
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
|
||||
// direct error because there's no redirect URI to ping
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
});
|
||||
|
||||
test('Disallow loopback', async () => {
|
||||
process.env.MISSKEY_TEST_CHECK_IP_RANGE = '1';
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
await assertDirectError(response, 400, 'invalid_request');
|
||||
});
|
||||
|
||||
test('Missing name', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
});
|
||||
|
||||
test('Mismatching URL in h-app', async () => {
|
||||
sender = (reply): void => {
|
||||
reply.header('Link', '</redirect>; rel="redirect_uri"');
|
||||
reply.send(`
|
||||
<!DOCTYPE html>
|
||||
<div class="h-app"><a href="/foo" class="u-url p-name">Misklient
|
||||
`);
|
||||
reply.send();
|
||||
};
|
||||
|
||||
const client = new AuthorizationCode(clientConfig);
|
||||
|
||||
const response = await fetch(client.authorizeURL({
|
||||
redirect_uri,
|
||||
scope: 'write:notes',
|
||||
state: 'state',
|
||||
code_challenge: 'code',
|
||||
code_challenge_method: 'S256',
|
||||
} as AuthorizationParamsExtended));
|
||||
assert.strictEqual(response.status, 200);
|
||||
assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`);
|
||||
});
|
||||
});
|
||||
|
||||
test('Unknown OAuth endpoint', async () => {
|
||||
const response = await fetch(new URL('/oauth/foo', host));
|
||||
assert.strictEqual(response.status, 404);
|
||||
});
|
||||
});
|
|
@ -95,7 +95,7 @@ const request = async (path: string, params: any, me?: UserToken): Promise<{ sta
|
|||
};
|
||||
};
|
||||
|
||||
const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
|
||||
export const relativeFetch = async (path: string, init?: RequestInit | undefined) => {
|
||||
return await fetch(new URL(path, `http://127.0.0.1:${port}/`).toString(), init);
|
||||
};
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
ref="el" class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike }]"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:value="value"
|
||||
@click="emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
|
@ -49,6 +51,8 @@ const props = defineProps<{
|
|||
large?: boolean;
|
||||
transparent?: boolean;
|
||||
asLike?: boolean;
|
||||
name?: string;
|
||||
value?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
67
packages/frontend/src/pages/oauth.vue
Normal file
67
packages/frontend/src/pages/oauth.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader/></template>
|
||||
<MkSpacer :contentMax="800">
|
||||
<div v-if="$i">
|
||||
<div v-if="permissions.length > 0">
|
||||
<p v-if="name">{{ i18n.t('_auth.permission', { name }) }}</p>
|
||||
<p v-else>{{ i18n.ts._auth.permissionAsk }}</p>
|
||||
<ul>
|
||||
<li v-for="p in permissions" :key="p">{{ i18n.t(`_permissions.${p}`) }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="name">{{ i18n.t('_auth.shareAccess', { name }) }}</div>
|
||||
<div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div>
|
||||
<form :class="$style.buttons" action="/oauth/decision" accept-charset="utf-8" method="post">
|
||||
<input name="login_token" type="hidden" :value="$i.token"/>
|
||||
<input name="transaction_id" type="hidden" :value="transactionIdMeta?.content"/>
|
||||
<MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||
<MkButton inline primary>{{ i18n.ts.accept }}</MkButton>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p>
|
||||
<MkSignin @login="onLogin"/>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkSignin from '@/components/MkSignin.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { $i, login } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
||||
const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]');
|
||||
if (transactionIdMeta) {
|
||||
transactionIdMeta.remove();
|
||||
}
|
||||
|
||||
const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content;
|
||||
const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ') ?? [];
|
||||
|
||||
function onLogin(res): void {
|
||||
login(res.i);
|
||||
}
|
||||
|
||||
definePageMetadata({
|
||||
title: 'OAuth',
|
||||
icon: 'ti ti-apps',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.buttons {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.loginMessage {
|
||||
text-align: center;
|
||||
margin: 8px 0 24px;
|
||||
}
|
||||
</style>
|
|
@ -263,6 +263,9 @@ export const routes = [{
|
|||
icon: 'icon',
|
||||
permission: 'permission',
|
||||
},
|
||||
}, {
|
||||
path: '/oauth/authorize',
|
||||
component: page(() => import('./pages/oauth.vue')),
|
||||
}, {
|
||||
path: '/tags/:tag',
|
||||
component: page(() => import('./pages/tag.vue')),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue