forked from FoundKeyGang/FoundKey
check redirect URIs
This commit is contained in:
parent
79e3c20189
commit
15b3ab6d13
4 changed files with 62 additions and 5 deletions
34
packages/backend/src/server/api/common/compare-url.ts
Normal file
34
packages/backend/src/server/api/common/compare-url.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { URL } from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two URLs for OAuth. The first parameter is the trusted URL
|
||||||
|
* which decides how the comparison is conducted.
|
||||||
|
*
|
||||||
|
* Implements the current draft-ietf-oauth-security-topics-21 § 4.1.3
|
||||||
|
* (published 2022-09-27)
|
||||||
|
*/
|
||||||
|
export function compareUrl(trusted: string, untrusted: string): boolean {
|
||||||
|
let trustedUrl = new URL(trusted);
|
||||||
|
let untrustedUrl = new URL(untrusted);
|
||||||
|
|
||||||
|
// Excerpt from RFC 8252:
|
||||||
|
//> Loopback redirect URIs use the "http" scheme and are constructed with
|
||||||
|
//> the loopback IP literal and whatever port the client is listening on.
|
||||||
|
//> That is, "http://127.0.0.1:{port}/{path}" for IPv4, and
|
||||||
|
//> "http://[::1]:{port}/{path}" for IPv6.
|
||||||
|
//
|
||||||
|
// To be nice we also include the "localhost" name, since it is required
|
||||||
|
// to resolve to one of the other two.
|
||||||
|
if (trustedUrl.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(trustedUrl.host)) {
|
||||||
|
// localhost comparisons should ignore port number
|
||||||
|
trustedUrl.port = '';
|
||||||
|
untrustedUrl.port = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// security recommendation is to just compare the (normalized) string
|
||||||
|
//> This document therefore advises to simplify the required logic and configuration
|
||||||
|
//> by using exact redirect URI matching. This means the authorization server MUST
|
||||||
|
//> compare the two URIs using simple string comparison as defined in [RFC3986],
|
||||||
|
//> Section 6.2.1.
|
||||||
|
return trustedUrl.href === untrustedUrl.href;
|
||||||
|
}
|
|
@ -1,15 +1,14 @@
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
import { IsNull, Not } from 'typeorm';
|
import { IsNull, Not } from 'typeorm';
|
||||||
import { Apps, AuthSessions, AccessTokens } from '@/models/index.js';
|
import { Apps, AuthSessions, AccessTokens } from '@/models/index.js';
|
||||||
import config from '@/config/index.js';
|
import config from '@/config/index.js';
|
||||||
|
import { compareUrl } from './compare-url.js';
|
||||||
|
|
||||||
export async function oauth(ctx: Koa.Context): void {
|
export async function oauth(ctx: Koa.Context): void {
|
||||||
const {
|
const {
|
||||||
grant_type,
|
grant_type,
|
||||||
code,
|
code,
|
||||||
// TODO: check redirect_uri
|
|
||||||
// since this is also not checked in the legacy app authentication
|
|
||||||
// it seems pointless to check it here, and it is also not stored.
|
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
} = ctx.request.body;
|
} = ctx.request.body;
|
||||||
|
|
||||||
|
@ -80,6 +79,16 @@ export async function oauth(ctx: Koa.Context): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check redirect URI
|
||||||
|
if (!compareUrl(app.callbackUrl, redirect_uri)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = {
|
||||||
|
error: 'invalid_grant',
|
||||||
|
error_description: 'Mismatched redirect_uri',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// session is single use
|
// session is single use
|
||||||
await AuthSessions.delete(session.id),
|
await AuthSessions.delete(session.id),
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { v4 as uuid } from 'uuid';
|
||||||
import config from '@/config/index.js';
|
import config from '@/config/index.js';
|
||||||
import { Apps, AuthSessions } from '@/models/index.js';
|
import { Apps, AuthSessions } from '@/models/index.js';
|
||||||
import { genId } from '@/misc/gen-id.js';
|
import { genId } from '@/misc/gen-id.js';
|
||||||
|
import { compareUrl } from '@/server/api/common/compare-url.js';
|
||||||
import define from '../../../define.js';
|
import define from '../../../define.js';
|
||||||
import { ApiError } from '../../../error.js';
|
import { ApiError } from '../../../error.js';
|
||||||
|
|
||||||
|
@ -43,14 +44,17 @@ export const meta = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const paramDef = {
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
oneOf: [{
|
oneOf: [{
|
||||||
type: 'object',
|
|
||||||
properties: {
|
properties: {
|
||||||
clientId: { type: 'string' },
|
clientId: { type: 'string' },
|
||||||
|
callbackUrl: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ['clientId']
|
required: ['clientId']
|
||||||
}, {
|
}, {
|
||||||
type: 'object',
|
|
||||||
properties: {
|
properties: {
|
||||||
appSecret: { type: 'string' },
|
appSecret: { type: 'string' },
|
||||||
},
|
},
|
||||||
|
@ -71,6 +75,14 @@ export default define(meta, paramDef, async (ps) => {
|
||||||
throw new ApiError('NO_SUCH_APP');
|
throw new ApiError('NO_SUCH_APP');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// check URL if provided
|
||||||
|
// technically the OAuth specification says that the redirect URI has to be
|
||||||
|
// bound with the token request, but since an app may only register one
|
||||||
|
// redirect URI, we don't actually have to store that.
|
||||||
|
if (ps.callbackUrl && !compareUrl(app.callbackUrl, ps.callbackUrl)) {
|
||||||
|
throw new ApiError('NO_SUCH_APP', 'redirect URI mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
// Generate token
|
// Generate token
|
||||||
const token = uuid();
|
const token = uuid();
|
||||||
const id = genId();
|
const id = genId();
|
||||||
|
|
|
@ -73,6 +73,8 @@ onMounted(async () => {
|
||||||
|
|
||||||
session = await os.api('auth/session/generate', {
|
session = await os.api('auth/session/generate', {
|
||||||
clientId,
|
clientId,
|
||||||
|
// make the server check the redirect, if provided
|
||||||
|
callbackUrl: params.get('redirect_uri') ?? undefined,
|
||||||
}).catch(e => {
|
}).catch(e => {
|
||||||
const response = {
|
const response = {
|
||||||
error: 'server_error',
|
error: 'server_error',
|
||||||
|
|
Loading…
Reference in a new issue