From 9cab2bf80f524d1ab3fe7b8fee1ca75d02becc4f Mon Sep 17 00:00:00 2001 From: Ben Francis Date: Sun, 4 Aug 2024 12:58:44 +0100 Subject: [PATCH] Implement OAuth 2.0 Authorization Server Metadata - closes #3143 --- src/constants.ts | 1 + src/controllers/well-known_controller.ts | 31 ++++++++++++++++++++++++ src/router.ts | 2 ++ src/test/integration/oauth-test.ts | 21 ++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 src/controllers/well-known_controller.ts diff --git a/src/constants.ts b/src/constants.ts index c4e8fd452..c0d8880e3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -32,6 +32,7 @@ export const INTERNAL_LOGS_PATH = '/internal-logs'; export const LOGS_PATH = '/logs'; export const PUSH_PATH = '/push'; export const PING_PATH = '/ping'; +export const WELL_KNOWN_PATH = '/.well-known'; export const PROXY_PATH = '/proxy'; export const EXTENSIONS_PATH = '/extensions'; // Remember we end up in the build/* directory so these paths looks slightly diff --git a/src/controllers/well-known_controller.ts b/src/controllers/well-known_controller.ts new file mode 100644 index 000000000..726a7a880 --- /dev/null +++ b/src/controllers/well-known_controller.ts @@ -0,0 +1,31 @@ +/** + * Well-Known Controller + * + * Handles HTTP requests to /.well-known + */ + +import express from 'express'; +import * as Constants from '../constants'; + +function build(): express.Router { + const controller = express.Router(); + + /** + * OAuth 2.0 Authorization Server Metadata (RFC 8414) + */ + controller.get('/oauth-authorization-server', (request, response) => { + const origin = `${request.protocol}://${request.headers.host}`; + response.json({ + issuer: origin, + authorization_endpoint: `${origin}${Constants.OAUTH_PATH}/authorize`, + token_endpoint: `${origin}${Constants.OAUTH_PATH}/token`, + response_types_supported: ['code'], + // Only expose top-level scopes to unauthenticated clients + scopes_supported: [Constants.THINGS_PATH, `${Constants.THINGS_PATH}:readwrite`], + }); + }); + + return controller; +} + +export default build; diff --git a/src/router.ts b/src/router.ts index 5c640dff7..335f1152b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -30,6 +30,7 @@ import NotifiersController from './controllers/notifiers_controller'; import OAuthClientsController from './controllers/oauthclients_controller'; import OAuthController from './controllers/oauth_controller'; import PingController from './controllers/ping_controller'; +import WellKnownController from './controllers/well-known_controller'; import ProxyController, { WithProxyMethods } from './controllers/proxy_controller'; import PushController from './controllers/push_controller'; import RootController from './controllers/root_controller'; @@ -155,6 +156,7 @@ class Router { app.use(API_PREFIX + Constants.SETTINGS_PATH, nocache, SettingsController()); app.use(API_PREFIX + Constants.USERS_PATH, nocache, UsersController()); app.use(API_PREFIX + Constants.PING_PATH, nocache, PingController()); + app.use(API_PREFIX + Constants.WELL_KNOWN_PATH, nocache, WellKnownController()); // Authenticated API routes app.use(API_PREFIX + Constants.THINGS_PATH, nocache, auth, ThingsController()); diff --git a/src/test/integration/oauth-test.ts b/src/test/integration/oauth-test.ts index a86f1aed5..b25a8e3d3 100644 --- a/src/test/integration/oauth-test.ts +++ b/src/test/integration/oauth-test.ts @@ -162,6 +162,27 @@ describe('oauth/', function () { customCallbackHandler = customCallbackHandlerProvided || null; } + it('serves OAuth metadata', async () => { + const res = await chai + .request(server) + .keepOpen() + .get('/.well-known/oauth-authorization-server') + .set('Accept', 'application/json'); + expect(res.status).toEqual(200); + expect(res.body).toHaveProperty('issuer'); + expect(res.body).toHaveProperty('authorization_endpoint'); + expect(res.body.authorization_endpoint).toEqual(expect.stringContaining('authorize')); + expect(res.body).toHaveProperty('token_endpoint'); + expect(res.body.token_endpoint).toEqual(expect.stringContaining('token')); + expect(res.body).toHaveProperty('response_types_supported'); + expect(res.body.response_types_supported.length).toEqual(1); + expect(res.body.response_types_supported[0]).toEqual('code'); + expect(res.body).toHaveProperty('scopes_supported'); + expect(res.body.scopes_supported.length).toEqual(2); + expect(res.body.scopes_supported).toContain('/things'); + expect(res.body.scopes_supported).toContain('/things:readwrite'); + }); + it('rejects request with no JWT', async () => { setupOAuth();