diff --git a/relay/application.py b/relay/application.py index 5ee1a73..38c39a6 100644 --- a/relay/application.py +++ b/relay/application.py @@ -352,7 +352,7 @@ async def handle_response_headers( resp.headers['Server'] = 'ActivityRelay' # Still have to figure out how csp headers work - if resp.content_type == 'text/html': + if resp.content_type == 'text/html' and not request.path.startswith("/api"): resp.headers['Content-Security-Policy'] = get_csp(request) if not request.app['dev'] and request.path.endswith(('.css', '.js', '.woff2')): diff --git a/relay/data/statements.sql b/relay/data/statements.sql index 0097252..dde6a29 100644 --- a/relay/data/statements.sql +++ b/relay/data/statements.sql @@ -51,7 +51,7 @@ WHERE username = :value or handle = :value; -- name: get-user-by-token SELECT * FROM users WHERE username = ( - SELECT user FROM app + SELECT user FROM apps WHERE token = :token ); @@ -68,12 +68,12 @@ WHERE username = :value or handle = :value; -- name: get-app -SELECT * FROM app +SELECT * FROM apps WHERE client_id = :id and client_secret = :secret; -- name: get-app-with-token -SELECT * FROM app +SELECT * FROM apps WHERE client_id = :id and client_secret = :secret and token = :token; diff --git a/relay/data/swagger.yaml b/relay/data/swagger.yaml index a2a51dc..ac7b728 100644 --- a/relay/data/swagger.yaml +++ b/relay/data/swagger.yaml @@ -18,10 +18,12 @@ securityDefinitions: in: cookie name: user-token Bearer: - type: apiKey + type: oauth2 name: Authorization in: header - description: "Enter the token with the `Bearer ` prefix" + flow: accessCode + authorizationUrl: /oauth/authorize + tokenUrl: /oauth/token paths: /: @@ -35,6 +37,161 @@ paths: schema: $ref: "#/definitions/Error" + /oauth/authorize: + get: + tags: + - OAuth + description: Get an authorization code + parameters: + - in: query + name: response-type + required: true + type: string + - in: query + name: client_id + required: true + type: string + - in: query + name: redirect_uri + required: true + type: string + + /oauth/token: + post: + tags: + - OAuth + description: Get a token for an authorized app + parameters: + - in: formData + name: grant_type + required: true + type: string + - in: formData + name: code + required: true + type: string + - in: formData + name: client_id + required: true + type: string + - in: formData + name: client_secret + required: true + type: string + - in: formData + name: redirect_uri + required: true + type: string + consumes: + - application/x-www-form-urlencoded + - application/json + - multipart/form-data + produces: + - application/json + responses: + "200": + description: Application + schema: + $ref: "#/definitions/Application" + + /oauth/revoke: + post: + tags: + - OAuth + description: Get a token for an authorized app + parameters: + - in: formData + name: client_id + required: true + type: string + - in: formData + name: client_secret + required: true + type: string + - in: formData + name: token + required: true + type: string + consumes: + - application/json + - multipart/form-data + - application/x-www-form-urlencoded + produces: + - application/json + responses: + "200": + description: Message confirming application deletion + schema: + $ref: "#/definitions/Message" + + /v1/app: + get: + tags: + - Applications + description: Verify the token is valid + produces: + - application/json + responses: + "200": + description: Application with the associated token + schema: + $ref: "#/definitions/Application" + + post: + tags: + - Applications + description: Create a new application + parameters: + - in: query + name: name + required: true + type: string + - in: query + name: redirect_uri + required: true + type: string + - in: query + name: website + required: false + type: string + format: url + consumes: + - application/json + - multipart/form-data + - application/x-www-form-urlencoded + produces: + - application/json + responses: + "200": + description: Newly created application + schema: + $ref: "#/definitions/Application" + + delete: + tags: + - Applications + description: Deletes an application + parameters: + - in: formData + name: client_id + required: true + type: string + - in: formData + name: client_secret + required: true + type: string + consumes: + - application/json + - multipart/form-data + - application/x-www-form-urlencoded + produces: + - application/json + responses: + "200": + description: Confirmation of application deletion + schema: + $ref: "#/definitions/Message" + /v1/relay: get: tags: @@ -48,23 +205,11 @@ paths: schema: $ref: "#/definitions/Info" - /v1/token: - get: - tags: - - Token - description: Verify API token - produces: - - application/json - responses: - "200": - description: Valid token - schema: - $ref: "#/definitions/Message" - + /v1/login: post: tags: - - Token - description: Get a new token + - Login + description: Login with a username and password parameters: - in: formData name: username @@ -74,7 +219,6 @@ paths: name: password required: true type: string - format: password consumes: - application/json - multipart/form-data @@ -83,22 +227,9 @@ paths: - application/json responses: "200": - description: Created token + description: A new Application schema: - $ref: "#/definitions/Token" - - - delete: - tags: - - Token - description: Revoke a token - produces: - - application/json - responses: - "200": - description: Revoked token - schema: - $ref: "#/definitions/Message" + $ref: "#/definitions/Application" /v1/config: get: @@ -731,9 +862,43 @@ definitions: description: Human-readable message text type: string + Application: + type: object + properties: + client_id: + description: Identifier for the application + type: string + client_secret: + description: Secret string for the application + type: string + name: + description: Human-readable name of the application + type: string + website: + description: Website for the application + type: string + format: url + redirect_uri: + description: URL to redirect to when authorizing an app + type: string + token: + description: String to use in the Authorization header for client requests + type: string + created: + description: Date the application was created + type: string + format: date-time + accessed: + description: Date the application was last used + type: string + format: date-time + Config: type: object properties: + approval-required: + description: Require instances to be approved when following + type: bool log-level: description: Maximum level of log messages to print to the console type: string @@ -743,6 +908,9 @@ definitions: note: description: Blurb to display on the home page type: string + theme: + description: Name of the color scheme to use for the frontend + type: string whitelist-enabled: description: Only allow specific instances to join the relay when enabled type: boolean @@ -843,13 +1011,6 @@ definitions: type: string format: date-time - Token: - type: object - properties: - token: - description: Character string used for authenticating with the api - type: string - User: type: object properties: diff --git a/relay/database/connection.py b/relay/database/connection.py index 603e63a..e18278a 100644 --- a/relay/database/connection.py +++ b/relay/database/connection.py @@ -364,7 +364,7 @@ class Connection(SqlConnection): 'client_secret': app.client_secret } - with self.update('app', data, **params) as cur: # type: ignore[arg-type] + with self.update('apps', data, **params) as cur: # type: ignore[arg-type] if (row := cur.one(schema.App)) is None: raise RuntimeError('Failed to update row') diff --git a/relay/views/api.py b/relay/views/api.py index f8fe828..b1b820b 100644 --- a/relay/views/api.py +++ b/relay/views/api.py @@ -40,7 +40,7 @@ async def handle_api_path( request: Request, handler: Callable[[Request], Awaitable[Response]]) -> Response: - if not request.path.startswith('/api'): + if not request.path.startswith('/api') or request.path == '/api/doc': return await handler(request) if request.method != "OPTIONS" and check_api_path(request.method, request.path): @@ -58,6 +58,7 @@ async def handle_api_path( @register_route('/oauth/authorize') +@register_route('/api/oauth/authorize') class OauthAuthorize(View): async def get(self, request: Request) -> Response: data = await self.get_api_data(['response_type', 'client_id', 'redirect_uri'], []) @@ -66,11 +67,14 @@ class OauthAuthorize(View): raise HttpError(400, 'Response type is not "code"') with self.database.session(True) as conn: - with conn.select('app', client_id = data['client_id']) as cur: + with conn.select('apps', client_id = data['client_id']) as cur: if (app := cur.one(schema.App)) is None: raise HttpError(404, 'Could not find app') - if app.token is not None or app.auth_code is not None: + if app.token is not None: + raise HttpError(400, 'Application has already been authorized') + + if app.auth_code is not None: context = {'application': app} html = self.template.render( 'page/authorize_show.haml', self.request, **context @@ -96,6 +100,9 @@ class OauthAuthorize(View): return Response.new_error(404, 'Could not find app', 'json') if convert_to_boolean(data['response']): + if app.token is not None: + raise HttpError(400, 'Application has already been authorized') + if app.auth_code is None: app = conn.update_app(app, request['user'], True) @@ -116,6 +123,7 @@ class OauthAuthorize(View): @register_route('/oauth/token') +@register_route('/api/oauth/token') class OauthToken(View): async def post(self, request: Request) -> Response: data = await self.get_api_data( @@ -141,6 +149,7 @@ class OauthToken(View): @register_route('/oauth/revoke') +@register_route('/api/oauth/revoke') class OauthRevoke(View): async def post(self, request: Request) -> Response: data = await self.get_api_data(['client_id', 'client_secret', 'token'], []) @@ -161,13 +170,7 @@ class OauthRevoke(View): @register_route('/api/v1/app') class App(View): async def get(self, request: Request) -> Response: - data = await self.get_api_data(['client_id', 'client_secret'], []) - - with self.database.session(False) as conn: - if (app := conn.get_app(data['client_id'], data['client_secret'])) is None: - raise HttpError(404, 'Application cannot be found') - - return Response.new(app.get_api_data(), ctype = 'json') + return Response.new(request['token'].get_api_data(), ctype = 'json') async def post(self, request: Request) -> Response: @@ -210,7 +213,7 @@ class Login(View): app = conn.put_app_login(user) - resp = Response.new({'token': app.token}, ctype = 'json') + resp = Response.new(app.get_api_data(), ctype = 'json') resp.set_cookie( 'user-token', app.token, # type: ignore[arg-type]