mode: indirect
This commit is contained in:
parent
b5df8ca0fd
commit
0d2041f5aa
2 changed files with 104 additions and 65 deletions
|
@ -111,6 +111,22 @@ interface OAuthRequest extends OAuth2Req {
|
||||||
codeChallengeMethod: string;
|
codeChallengeMethod: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getQueryMode(issuerUrl: string): oauth2orize.grant.Options['modes'] {
|
||||||
|
return {
|
||||||
|
query: (txn, res, params): void => {
|
||||||
|
// RFC 9207
|
||||||
|
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 any).redirect(parsed.toString());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class OAuth2Store {
|
class OAuth2Store {
|
||||||
#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // 5min
|
#cache = new MemoryKVCache<OAuth2>(1000 * 60 * 5); // 5min
|
||||||
|
|
||||||
|
@ -128,13 +144,13 @@ class OAuth2Store {
|
||||||
cb(null, loaded);
|
cb(null, loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
store(req: any, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void {
|
store(req: unknown, oauth2: OAuth2, cb: (err: Error | null, transactionID?: string) => void): void {
|
||||||
const transactionId = secureRndstr(128, true);
|
const transactionId = secureRndstr(128, true);
|
||||||
this.#cache.set(transactionId, oauth2);
|
this.#cache.set(transactionId, oauth2);
|
||||||
cb(null, transactionId);
|
cb(null, transactionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(req: any, tid: string, cb: () => void): void {
|
remove(req: unknown, tid: string, cb: () => void): void {
|
||||||
this.#cache.delete(tid);
|
this.#cache.delete(tid);
|
||||||
cb();
|
cb();
|
||||||
}
|
}
|
||||||
|
@ -168,19 +184,7 @@ export class OAuth2ProviderService {
|
||||||
|
|
||||||
this.#server.grant(oauth2Pkce.extensions());
|
this.#server.grant(oauth2Pkce.extensions());
|
||||||
this.#server.grant(oauth2orize.grant.code({
|
this.#server.grant(oauth2orize.grant.code({
|
||||||
modes: {
|
modes: getQueryMode(config.url),
|
||||||
query: (txn, res, params) => {
|
|
||||||
// RFC 9207
|
|
||||||
params.iss = config.url;
|
|
||||||
|
|
||||||
const parsed = new URL(txn.redirectURI);
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
|
||||||
parsed.searchParams.append(key, value as string);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (res as any).redirect(parsed.toString());
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}, (client, redirectUri, token, ares, areq, locals, done) => {
|
}, (client, redirectUri, token, ares, areq, locals, done) => {
|
||||||
(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => {
|
(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => {
|
||||||
console.log('HIT grant code:', client, redirectUri, token, ares, areq);
|
console.log('HIT grant code:', client, redirectUri, token, ares, areq);
|
||||||
|
@ -303,27 +307,14 @@ export class OAuth2ProviderService {
|
||||||
|
|
||||||
await fastify.register(fastifyExpress);
|
await fastify.register(fastifyExpress);
|
||||||
fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => {
|
fastify.use('/oauth/authorize', this.#server.authorize(((areq, done) => {
|
||||||
(async (): Promise<OmitFirstElement<Parameters<typeof done>>> => {
|
(async (): Promise<Parameters<typeof done>> => {
|
||||||
console.log('HIT /oauth/authorize validation middleware', areq);
|
console.log('HIT /oauth/authorize validation middleware', areq);
|
||||||
|
|
||||||
|
// 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, type } = areq as OAuthRequest;
|
const { codeChallenge, codeChallengeMethod, clientID, redirectURI, scope, type } = areq as OAuthRequest;
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
if (type !== 'code') {
|
|
||||||
throw new AuthorizationError('`response_type` parameter must be set as "code"', 'invalid_request');
|
|
||||||
}
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientUrl = validateClientId(clientID);
|
const clientUrl = validateClientId(clientID);
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') {
|
if (process.env.NODE_ENV !== 'test' || process.env.MISSKEY_TEST_DISALLOW_LOOPBACK === '1') {
|
||||||
|
@ -339,13 +330,33 @@ export class OAuth2ProviderService {
|
||||||
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
|
throw new AuthorizationError('Invalid redirect_uri', 'invalid_request');
|
||||||
}
|
}
|
||||||
|
|
||||||
return [clientInfo, redirectURI];
|
try {
|
||||||
})().then(args => done(null, ...args), err => done(err));
|
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;
|
||||||
|
|
||||||
|
if (type !== 'code') {
|
||||||
|
throw new AuthorizationError('`response_type` parameter must be set as "code"', 'invalid_request');
|
||||||
|
}
|
||||||
|
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));
|
}) as ValidateFunctionArity2));
|
||||||
// TODO: use mode: indirect
|
fastify.use('/oauth/authorize', this.#server.errorHandler({
|
||||||
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-4.1.2.1
|
mode: 'indirect',
|
||||||
// But make sure not to redirect to an invalid redirect_uri
|
modes: getQueryMode(this.config.url),
|
||||||
fastify.use('/oauth/authorize', this.#server.errorHandler());
|
}));
|
||||||
|
|
||||||
fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false }));
|
fastify.use('/oauth/decision', bodyParser.urlencoded({ extended: false }));
|
||||||
fastify.use('/oauth/decision', this.#server.decision((req, done) => {
|
fastify.use('/oauth/decision', this.#server.decision((req, done) => {
|
||||||
|
|
|
@ -32,6 +32,10 @@ interface OAuthErrorResponse {
|
||||||
error_description: string;
|
error_description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OAuthErrorDirectResponse {
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthorizationParamsExtended {
|
interface AuthorizationParamsExtended {
|
||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
scope: string | string[];
|
scope: string | string[];
|
||||||
|
@ -294,9 +298,12 @@ describe('OAuth', () => {
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
scope: 'write:notes',
|
scope: 'write:notes',
|
||||||
state: 'state',
|
state: 'state',
|
||||||
}));
|
}), { redirect: 'manual' });
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 302);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
|
||||||
|
let location = response.headers.get('location');
|
||||||
|
assert.ok(location);
|
||||||
|
assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request');
|
||||||
|
|
||||||
// Pattern 2: Only code_challenge
|
// Pattern 2: Only code_challenge
|
||||||
response = await fetch(client.authorizeURL({
|
response = await fetch(client.authorizeURL({
|
||||||
|
@ -304,9 +311,12 @@ describe('OAuth', () => {
|
||||||
scope: 'write:notes',
|
scope: 'write:notes',
|
||||||
state: 'state',
|
state: 'state',
|
||||||
code_challenge: 'code',
|
code_challenge: 'code',
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 302);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
|
||||||
|
location = response.headers.get('location');
|
||||||
|
assert.ok(location);
|
||||||
|
assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request');
|
||||||
|
|
||||||
// Pattern 2: Only code_challenge_method
|
// Pattern 2: Only code_challenge_method
|
||||||
response = await fetch(client.authorizeURL({
|
response = await fetch(client.authorizeURL({
|
||||||
|
@ -314,9 +324,12 @@ describe('OAuth', () => {
|
||||||
scope: 'write:notes',
|
scope: 'write:notes',
|
||||||
state: 'state',
|
state: 'state',
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 302);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
|
||||||
|
location = response.headers.get('location');
|
||||||
|
assert.ok(location);
|
||||||
|
assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request');
|
||||||
|
|
||||||
// Pattern 3: Unsupported code_challenge_method
|
// Pattern 3: Unsupported code_challenge_method
|
||||||
response = await fetch(client.authorizeURL({
|
response = await fetch(client.authorizeURL({
|
||||||
|
@ -325,9 +338,12 @@ describe('OAuth', () => {
|
||||||
state: 'state',
|
state: 'state',
|
||||||
code_challenge: 'code',
|
code_challenge: 'code',
|
||||||
code_challenge_method: 'SSSS',
|
code_challenge_method: 'SSSS',
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 302);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
|
||||||
|
location = response.headers.get('location');
|
||||||
|
assert.ok(location);
|
||||||
|
assert.strictEqual(new URL(location).searchParams.get('error'), 'invalid_request');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use precomputed challenge/verifier set here for deterministic test
|
// Use precomputed challenge/verifier set here for deterministic test
|
||||||
|
@ -407,7 +423,10 @@ describe('OAuth', () => {
|
||||||
const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true });
|
const decisionResponse = await fetchDecisionFromResponse(response, alice, { cancel: true });
|
||||||
assert.strictEqual(decisionResponse.status, 302);
|
assert.strictEqual(decisionResponse.status, 302);
|
||||||
|
|
||||||
const location = new URL(decisionResponse.headers.get('location')!);
|
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('code'));
|
||||||
assert.ok(location.searchParams.has('error'));
|
assert.ok(location.searchParams.has('error'));
|
||||||
});
|
});
|
||||||
|
@ -421,10 +440,13 @@ describe('OAuth', () => {
|
||||||
state: 'state',
|
state: 'state',
|
||||||
code_challenge: 'code',
|
code_challenge: 'code',
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assert.strictEqual(response.status, 302);
|
||||||
|
|
||||||
assert.strictEqual(response.status, 400);
|
const locationHeader = response.headers.get('location');
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope');
|
assert.ok(locationHeader);
|
||||||
|
|
||||||
|
assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Empty scope', async () => {
|
test('Empty scope', async () => {
|
||||||
|
@ -436,10 +458,13 @@ describe('OAuth', () => {
|
||||||
state: 'state',
|
state: 'state',
|
||||||
code_challenge: 'code',
|
code_challenge: 'code',
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assert.strictEqual(response.status, 302);
|
||||||
|
|
||||||
assert.strictEqual(response.status, 400);
|
const locationHeader = response.headers.get('location');
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope');
|
assert.ok(locationHeader);
|
||||||
|
|
||||||
|
assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Unknown scopes', async () => {
|
test('Unknown scopes', async () => {
|
||||||
|
@ -451,10 +476,13 @@ describe('OAuth', () => {
|
||||||
state: 'state',
|
state: 'state',
|
||||||
code_challenge: 'code',
|
code_challenge: 'code',
|
||||||
code_challenge_method: 'S256',
|
code_challenge_method: 'S256',
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended), { redirect: 'manual' });
|
||||||
|
assert.strictEqual(response.status, 302);
|
||||||
|
|
||||||
assert.strictEqual(response.status, 400);
|
const locationHeader = response.headers.get('location');
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_scope');
|
assert.ok(locationHeader);
|
||||||
|
|
||||||
|
assert.strictEqual(new URL(locationHeader).searchParams.get('error'), 'invalid_scope');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Partially known scopes', async () => {
|
test('Partially known scopes', async () => {
|
||||||
|
@ -584,7 +612,7 @@ describe('OAuth', () => {
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended));
|
||||||
|
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 400);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Invalid redirect_uri including the valid one at authorization endpoint', async () => {
|
test('Invalid redirect_uri including the valid one at authorization endpoint', async () => {
|
||||||
|
@ -599,7 +627,7 @@ describe('OAuth', () => {
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended));
|
||||||
|
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 400);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('No redirect_uri at authorization endpoint', async () => {
|
test('No redirect_uri at authorization endpoint', async () => {
|
||||||
|
@ -613,7 +641,7 @@ describe('OAuth', () => {
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended));
|
||||||
|
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 400);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Invalid redirect_uri at token endpoint', async () => {
|
test('Invalid redirect_uri at token endpoint', async () => {
|
||||||
|
@ -799,7 +827,7 @@ describe('OAuth', () => {
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended));
|
||||||
|
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 400);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -816,7 +844,7 @@ describe('OAuth', () => {
|
||||||
} as AuthorizationParamsExtended));
|
} as AuthorizationParamsExtended));
|
||||||
|
|
||||||
assert.strictEqual(response.status, 400);
|
assert.strictEqual(response.status, 400);
|
||||||
assert.strictEqual((await response.json() as OAuthErrorResponse).error, 'invalid_request');
|
assert.strictEqual((await response.json() as OAuthErrorDirectResponse).code, 'invalid_request');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Missing name', async () => {
|
test('Missing name', async () => {
|
||||||
|
|
Loading…
Reference in a new issue