├── .github └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── datasette_auth_tokens ├── __init__.py ├── migrations.py ├── templates │ ├── create_api_token.html │ ├── token_details.html │ └── tokens_index.html ├── utils.py └── views.py ├── setup.py └── tests ├── test_auth_tokens.py ├── test_managed_tokens.py └── test_migrations.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | cache: pip 20 | cache-dependency-path: setup.py 21 | - name: Install dependencies 22 | run: | 23 | pip install -e '.[test]' 24 | - name: Run tests 25 | run: | 26 | pytest 27 | deploy: 28 | runs-on: ubuntu-latest 29 | needs: [test] 30 | environment: release 31 | permissions: 32 | id-token: write 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Set up Python 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: "3.12" 39 | cache: pip 40 | cache-dependency-path: setup.py 41 | - name: Install dependencies 42 | run: | 43 | pip install setuptools wheel build 44 | - name: Build 45 | run: | 46 | python -m build 47 | - name: Publish 48 | uses: pypa/gh-action-pypi-publish@release/v1 49 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | cache: pip 18 | cache-dependency-path: setup.py 19 | - name: Install dependencies 20 | run: | 21 | pip install -e '.[test]' 22 | - name: Run tests 23 | run: | 24 | pytest 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | venv 6 | .eggs 7 | .pytest_cache 8 | *.egg-info -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # datasette-auth-tokens 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/datasette-auth-tokens.svg)](https://pypi.org/project/datasette-auth-tokens/) 4 | [![Changelog](https://img.shields.io/github/v/release/simonw/datasette-auth-tokens?include_prereleases&label=changelog)](https://github.com/simonw/datasette-auth-tokens/releases) 5 | [![Tests](https://github.com/simonw/datasette-auth-tokens/workflows/Test/badge.svg)](https://github.com/simonw/datasette-auth-tokens/actions?query=workflow%3ATest) 6 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/datasette-auth-tokens/blob/main/LICENSE) 7 | 8 | Datasette plugin for authenticating access using API tokens 9 | 10 | ## Installation 11 | 12 | Install this plugin in the same environment as Datasette. 13 | ```bash 14 | datasette install datasette-auth-tokens 15 | ``` 16 | ## Hard-coded tokens 17 | 18 | Read about Datasette's [authentication and permissions system](https://datasette.readthedocs.io/en/latest/authentication.html). 19 | 20 | This plugin lets you configure secret API tokens which can be used to make authenticated requests to Datasette. 21 | 22 | First, create a random API token. A useful recipe for doing that is the following: 23 | ```bash 24 | python -c 'import secrets; print(secrets.token_hex(32))' 25 | ``` 26 | ``` 27 | 5f9a486dd807de632200b17508c75002bb66ca6fde1993db1de6cbd446362589 28 | ``` 29 | Decide on the actor that this token should represent, for example: 30 | 31 | ```json 32 | { 33 | "bot_id": "my-bot" 34 | } 35 | ``` 36 | 37 | You can then use `"allow"` blocks to provide that token with permission to access specific actions. To enable access to a configured writable SQL query you could use this in your `config.json` (for Datasette 1.0) or `metadata.json`: 38 | 39 | ```json 40 | { 41 | "plugins": { 42 | "datasette-auth-tokens": { 43 | "tokens": [ 44 | { 45 | "token": { 46 | "$env": "BOT_TOKEN" 47 | }, 48 | "actor": { 49 | "bot_id": "my-bot" 50 | } 51 | } 52 | ] 53 | } 54 | }, 55 | "databases": { 56 | ":memory:": { 57 | "queries": { 58 | "show_version": { 59 | "sql": "select sqlite_version()", 60 | "allow": { 61 | "bot_id": "my-bot" 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | ``` 69 | This uses Datasette's [secret configuration values mechanism](https://datasette.readthedocs.io/en/stable/plugins.html#secret-configuration-values) to allow the secret token to be passed as an environment variable. 70 | 71 | Run Datasette like this: 72 | ```bash 73 | BOT_TOKEN="this-is-the-secret-token" \ 74 | datasette -c config.json 75 | ``` 76 | You can now run authenticated API queries like this: 77 | ```bash 78 | curl -H 'Authorization: Bearer this-is-the-secret-token' \ 79 | 'http://127.0.0.1:8001/:memory:/show_version.json?_shape=array' 80 | ``` 81 | ```json 82 | [{"sqlite_version()": "3.31.1"}] 83 | ``` 84 | Additionally you can allow passing the token as a query string parameter, although that's disabled by default given the security implications of URLs with secret tokens included. This may be useful to easily allow embedding data between different services. 85 | 86 | Enable it using the `param` config value: 87 | 88 | ```json 89 | { 90 | "plugins": { 91 | "datasette-auth-tokens": { 92 | "tokens": [ 93 | { 94 | "token": { 95 | "$env": "BOT_TOKEN" 96 | }, 97 | "actor": { 98 | "bot_id": "my-bot" 99 | }, 100 | } 101 | ], 102 | "param": "_auth_token" 103 | } 104 | }, 105 | "databases": { 106 | ":memory:": { 107 | "queries": { 108 | "show_version": { 109 | "sql": "select sqlite_version()", 110 | "allow": { 111 | "bot_id": "my-bot" 112 | } 113 | } 114 | } 115 | } 116 | } 117 | } 118 | ``` 119 | 120 | You can now run authenticated API queries like this: 121 | ```bash 122 | curl http://127.0.0.1:8001/:memory:/show_version.json?_shape=array&_auth_token=this-is-the-secret-token 123 | ``` 124 | ```json 125 | [{"sqlite_version()": "3.31.1"}] 126 | ``` 127 | ## Managed tokens mode 128 | 129 | `datasette-auth-tokens` provides a managed tokens mode, where tokens are stored in a SQLite database table and the plugin provides an interface for creating and revoking tokens. 130 | 131 | To turn this mode on, add `"manage_tokens": true` to your plugin configuration: 132 | 133 | ```json 134 | { 135 | "plugins": { 136 | "datasette-auth-tokens": { 137 | "manage_tokens": true 138 | } 139 | } 140 | } 141 | ``` 142 | This will add a "Create API token" option to the Datasette menu. 143 | 144 | Tokens that are created will be kept in a new `_datasette_auth_tokens` table. 145 | 146 | Users need the `auth-tokens-create` permission to create tokens. One way to grant that is to add this `"permissions"` block to your configuration: 147 | 148 | ```json 149 | { 150 | "permissions": { 151 | "auth-tokens-create": { 152 | "id": "*" 153 | } 154 | } 155 | } 156 | ``` 157 | 158 | Use the "Create API token" option in the Datasette menu or navigate to `/-/api/tokens` to create tokens and manage tokens. 159 | 160 | When you create a new token a signed token string will be presented to you. You need to store this, as it is not stored directly in the database table and can only be retrieved once. 161 | 162 | If you have multiple databases attached to Datasette you will need to specify which database should be used for the `_datasette_auth_tokens` table. You can do this with the `manage_tokens_database` setting: 163 | 164 | ```json 165 | { 166 | "plugins": { 167 | "datasette-auth-tokens": { 168 | "manage_tokens": true, 169 | "manage_tokens_database": "tokens" 170 | } 171 | } 172 | } 173 | ``` 174 | Now start Datasette like this: 175 | ```bash 176 | datasette -c config.json mydb.db tokens.db --create 177 | ``` 178 | The `--create` option can be used to tell Datasette to create the `tokens.db` database file if it does not already exist. 179 | 180 | In Datasette 1.0 you can instead use the `-s` option like this: 181 | ```bash 182 | datasette \ 183 | -s plugins.datasette-auth-tokens.manage_tokens true \ 184 | -s plugins.datasette-auth-tokens.manage_tokens_database tokens \ 185 | -s permissions.auth-tokens-create.id '*' # to enable token creation 186 | ``` 187 | 188 | ### Viewing tokens 189 | 190 | By default, users can only view tokens that they themselves have created on the `/-/api/tokens` page. 191 | 192 | Grant the `auth-tokens-view-all` permission to allow a user to view all tokens, even those created by other users. 193 | 194 | ### Revoking tokens 195 | 196 | A token can be revoked by the user that created it by clicking the "Revoke this token" button at the bottom of the token page that is linked to from `/-/api/tokens`. 197 | 198 | A user with the `auth-tokens-revoke-all` permission can revoke any token. 199 | 200 | ## Custom tokens from your database 201 | 202 | If you decide not to use managed tokens mode, you can instead configure `datasette-auth-tokens` to use tokens that are stored in your own custom database tables. 203 | 204 | You can do this by configuring a custom SQL query that will execute to test if an incoming token is valid. 205 | 206 | Your query needs to take a `:token_id` parameter and return at least two columns: one called `token_secret` and one called `actor_*` - usually `actor_id`. Further `actor_` prefixed columns can be returned to provide more details for the authenticated actor. 207 | 208 | Here's a simple example of a configuration query: 209 | 210 | ```sql 211 | select actor_id, actor_name, token_secret from tokens where token_id = :token_id 212 | ``` 213 | 214 | This can run against a table like this one: 215 | 216 | | token_id | token_secret | actor_id | actor_name | 217 | | -------- | ------------ | -------- | ---------- | 218 | | 1 | bd3c94f51fcd | 78 | Cleopaws | 219 | | 2 | 86681b4d6f66 | 32 | Pancakes | 220 | 221 | The tokens are formed as the token ID, then a hyphen, then the token secret. For example: 222 | 223 | - `1-bd3c94f51fcd` 224 | - `2-86681b4d6f66` 225 | 226 | The SQL query will be executed with the portion before the hyphen as the `:token_id` parameter. 227 | 228 | The `token_secret` value returned by the query will be compared to the portion of the token after the hyphen to check if the token is valid. 229 | 230 | Columns with a prefix of `actor_` will be used to populate the actor dictionary. In the above example, a token of `2-86681b4d6f66` will become an actor dictionary of `{"id": 32, "name": "Pancakes"}`. 231 | 232 | To configure this, use a `"query"` block in your plugin configuration like this: 233 | 234 | ```json 235 | { 236 | "plugins": { 237 | "datasette-auth-tokens": { 238 | "query": { 239 | "sql": "select actor_id, actor_name, token_secret from tokens where token_id = :token_id", 240 | "database": "tokens" 241 | } 242 | } 243 | }, 244 | "databases": { 245 | "tokens": { 246 | "allow": false 247 | } 248 | } 249 | } 250 | ``` 251 | The `"sql"` key here contains the SQL query. The `"database"` key has the name of the attached database file that the query should be executed against - in this case it would execute against `tokens.db`. 252 | 253 | ### Securing your custom tokens 254 | 255 | If you implement the custom pattern above which reads `token_secret` from your own `tokens` table, you need to be aware that anyone with read access to your Datasette instance could read those tokens from your table. This probably isn't what you want! 256 | 257 | To avoid this, you should lock down access to that table. The configuration example above shows how to do this using an `"allow": false` block to deny all access to that `tokens` database. 258 | 259 | Consult Datasette's [Permissions documentation](https://datasette.readthedocs.io/en/stable/authentication.html#permissions) for more information about how to lock down this kind of access. 260 | -------------------------------------------------------------------------------- /datasette_auth_tokens/__init__.py: -------------------------------------------------------------------------------- 1 | from datasette import hookimpl, Forbidden, Permission 2 | import itsdangerous 3 | import json 4 | import secrets 5 | import sqlite_utils 6 | import time 7 | from markupsafe import Markup 8 | from .views import ( 9 | create_api_token, 10 | check_permission, 11 | tokens_index, 12 | token_details, 13 | Config, 14 | ) 15 | from .migrations import migration 16 | 17 | TOKEN_STATUSES = { 18 | "A": "Active", 19 | "R": "Revoked", 20 | "E": "Expired", 21 | } 22 | 23 | 24 | @hookimpl 25 | def table_actions(datasette, actor, database, table): 26 | if actor and table == "_datasette_auth_tokens": 27 | return menu_links(datasette, actor) 28 | 29 | 30 | @hookimpl 31 | def menu_links(datasette, actor): 32 | if not actor: 33 | return 34 | 35 | async def inner(): 36 | try: 37 | await check_permission(datasette, actor) 38 | except Forbidden: 39 | return 40 | return [ 41 | { 42 | "href": datasette.urls.path("/-/api/tokens/create"), 43 | "label": "Create API token", 44 | } 45 | ] 46 | 47 | return inner 48 | 49 | 50 | @hookimpl 51 | def startup(datasette): 52 | config = Config(datasette) 53 | if not config.enabled: 54 | return 55 | 56 | db = config.db 57 | 58 | async def inner(): 59 | def migrate(conn): 60 | db = sqlite_utils.Database(conn) 61 | migration.apply(db) 62 | 63 | await db.execute_write_fn(migrate) 64 | 65 | return inner 66 | 67 | 68 | @hookimpl 69 | def register_routes(datasette): 70 | config = Config(datasette) 71 | if not config.enabled: 72 | return 73 | return [ 74 | (r"^/-/api/tokens/create$", create_api_token), 75 | (r"^/-/api/tokens$", tokens_index), 76 | (r"^/-/api/tokens/(?P\d+)$", token_details), 77 | ] 78 | 79 | 80 | @hookimpl 81 | def register_permissions(): 82 | return [ 83 | Permission( 84 | name="auth-tokens-revoke-all", 85 | abbr=None, 86 | description="Revoke any API tokens", 87 | takes_database=False, 88 | takes_resource=False, 89 | default=False, 90 | ), 91 | Permission( 92 | name="auth-tokens-view-all", 93 | abbr=None, 94 | description="View all API tokens", 95 | takes_database=False, 96 | takes_resource=False, 97 | default=False, 98 | ), 99 | Permission( 100 | name="auth-tokens-create", 101 | abbr=None, 102 | description="Create API tokens", 103 | takes_database=False, 104 | takes_resource=False, 105 | # If this was True anonymous users would be able to create tokens: 106 | default=False, 107 | ), 108 | ] 109 | 110 | 111 | @hookimpl 112 | def actor_from_request(datasette, request): 113 | async def inner(): 114 | config = Config(datasette) 115 | allowed_tokens = config.get("tokens") or [] 116 | query_param = config.get("param") 117 | authorization = request.headers.get("authorization") 118 | if authorization: 119 | if not authorization.startswith("Bearer "): 120 | return None 121 | incoming_token = authorization[len("Bearer ") :] 122 | elif query_param: 123 | query_param_token = request.args.get(query_param) 124 | if query_param_token: 125 | incoming_token = query_param_token 126 | else: 127 | return None 128 | else: 129 | return None 130 | 131 | if config.enabled: 132 | return await _actor_from_managed(datasette, incoming_token) 133 | 134 | # First try hard-coded tokens in the list 135 | for token in allowed_tokens: 136 | if secrets.compare_digest(token["token"], incoming_token): 137 | return token["actor"] 138 | # Now try the SQL query, if present 139 | query = config.get("query") 140 | if query: 141 | if "-" not in incoming_token: 142 | # Invalid token 143 | return None 144 | token_id, token_secret = incoming_token.split("-", 2) 145 | sql = query["sql"] 146 | database = query.get("database") 147 | db = datasette.get_database(database) 148 | results = await db.execute(sql, {"token_id": token_id}) 149 | if not results: 150 | return None 151 | row = results.first() 152 | assert ( 153 | "token_secret" in row.keys() 154 | ), "Returned row must contain a token_secret" 155 | if secrets.compare_digest(row["token_secret"], token_secret): 156 | # Set actor based on actor_* columns 157 | return { 158 | k.replace("actor_", ""): row[k] 159 | for k in row.keys() 160 | if k.startswith("actor_") 161 | } 162 | 163 | return inner 164 | 165 | 166 | async def _actor_from_managed(datasette, incoming_token): 167 | config = Config(datasette) 168 | db = config.db 169 | if not incoming_token.startswith("dsatok_"): 170 | return None 171 | incoming_token = incoming_token[len("dsatok_") :] 172 | try: 173 | token_id = datasette.unsign(incoming_token, "dsatok") 174 | except itsdangerous.BadSignature: 175 | return None 176 | 177 | # Potentially expire token first 178 | await db.execute_write_fn(make_expire_function(token_id)) 179 | 180 | results = await db.execute( 181 | "select * from _datasette_auth_tokens where id=:token_id", 182 | {"token_id": token_id}, 183 | ) 184 | row = results.first() 185 | if not row: 186 | return None 187 | 188 | actor = { 189 | "id": row["actor_id"], 190 | "token": "dsatok", 191 | "token_id": row["id"], 192 | } 193 | permissions = json.loads(row["permissions"]) 194 | if permissions: 195 | actor["_r"] = permissions 196 | 197 | # Is token revoked? 198 | if row["token_status"] == "R": 199 | return None 200 | 201 | # Expired? 202 | if row["token_status"] == "E": 203 | return None 204 | 205 | # Update last_used_timestamp if more than 60 seconds old 206 | if row["last_used_timestamp"] is None or ( 207 | row["last_used_timestamp"] < (time.time() - 60) 208 | ): 209 | await db.execute_write( 210 | "update _datasette_auth_tokens set last_used_timestamp=:now where id=:token_id", 211 | {"now": int(time.time()), "token_id": token_id}, 212 | ) 213 | 214 | return actor 215 | 216 | 217 | def make_expire_function(token_id=None): 218 | where_bits = [ 219 | "token_status = 'A'", 220 | "expires_after_seconds is not null", 221 | "(created_timestamp + expires_after_seconds) < :now", 222 | ] 223 | if token_id: 224 | where_bits.append("id = :token_id") 225 | 226 | def expire_tokens(conn): 227 | # Expire all tokens that are due to expire - or just specified token 228 | with conn: 229 | conn.execute( 230 | """ 231 | update _datasette_auth_tokens 232 | set token_status = 'E', ended_timestamp = :now 233 | where {where} 234 | """.format( 235 | where=" and ".join(where_bits) 236 | ), 237 | {"now": int(time.time()), "token_id": token_id}, 238 | ) 239 | 240 | return expire_tokens 241 | 242 | 243 | @hookimpl 244 | def render_cell(value, column, table, row): 245 | if table != "_datasette_auth_tokens": 246 | return None 247 | if column.endswith("_timestamp"): 248 | return value and time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(value)) 249 | if column != "token_status": 250 | return None 251 | return Markup( 252 | ('{status}
{link}').format( 253 | status=TOKEN_STATUSES.get(value, value), 254 | id=row["id"], 255 | link="edit / revoke" if value == "L" else "view", 256 | ) 257 | ) 258 | -------------------------------------------------------------------------------- /datasette_auth_tokens/migrations.py: -------------------------------------------------------------------------------- 1 | from sqlite_migrate import Migrations 2 | import time 3 | 4 | migration = Migrations("datasette_auth_tokens") 5 | 6 | 7 | # Use this decorator against functions that implement migrations 8 | @migration() 9 | def m001_create_table(db): 10 | # If the table exists already, this will be a no-op 11 | db.execute( 12 | """ 13 | CREATE TABLE IF NOT EXISTS _datasette_auth_tokens ( 14 | id INTEGER PRIMARY KEY, 15 | token_status TEXT DEFAULT 'A', -- [A]ctive, [R]evoked, [E]xpired 16 | description TEXT, 17 | actor_id TEXT, 18 | permissions TEXT, 19 | created_timestamp INTEGER, 20 | last_used_timestamp INTEGER, 21 | expires_after_seconds INTEGER, 22 | secret_version INTEGER DEFAULT 0 23 | ); 24 | """ 25 | ) 26 | 27 | 28 | @migration() 29 | def m002_rename_live_to_active(db): 30 | # In case anything is left over - I made this change before 31 | # I introduced migrations 32 | db["_datasette_auth_tokens"].transform(defaults={"token_status": "A"}) 33 | db.query( 34 | """ 35 | update _datasette_auth_tokens 36 | set token_status = 'A' 37 | where token_status = 'L' 38 | """ 39 | ) 40 | 41 | 42 | @migration() 43 | def m003_add_ended_timestamp(db): 44 | db["_datasette_auth_tokens"].add_column("ended_timestamp", int) 45 | # Switch order around 46 | db["_datasette_auth_tokens"].transform( 47 | column_order=[ 48 | "id", 49 | "token_status", 50 | "description", 51 | "actor_id", 52 | "permissions", 53 | "created_timestamp", 54 | "last_used_timestamp", 55 | "expires_after_seconds", 56 | "ended_timestamp", 57 | "secret_version", 58 | ] 59 | ) 60 | # Set it to now for any revoked tokens 61 | db.query( 62 | "update _datasette_auth_tokens set ended_timestamp = :now where token_status = 'R'", 63 | {"now": int(time.time())}, 64 | ) 65 | # Set it to created_timestamp + expires_after_seconds for any expired tokens 66 | db.query( 67 | """ 68 | update _datasette_auth_tokens 69 | set ended_timestamp = created_timestamp + expires_after_seconds 70 | where token_status = 'E' 71 | """ 72 | ) 73 | -------------------------------------------------------------------------------- /datasette_auth_tokens/templates/create_api_token.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}Create an API token{% endblock %} 4 | 5 | {% block extra_head %} 6 | 16 | {% endblock %} 17 | 18 | {% block content %} 19 | 20 |

Create an API token

21 | 22 | {% if tokens_exist %}

Manage existing tokens

{% endif %} 23 | 24 |

This token will allow API access with the same abilities as your current user, {{ request.actor.username or request.actor.id }}

25 | 26 | {% if token %} 27 |
28 |

Your API token

29 |
30 | 31 | 32 |
33 |

For security reasons this token will only appear here once. Copy it somewhere safe.

34 | 35 |
36 | Token details 37 |
{{ token_bits|tojson(4) }}
38 |
39 |
40 |

Create another token

41 | {% endif %} 42 | 43 | {% if errors %} 44 | {% for error in errors %} 45 |

{{ error }}

46 | {% endfor %} 47 | {% endif %} 48 | 49 |
50 |
51 |
52 | 53 |
54 |
55 | 61 |
62 | 63 | 64 | 65 | 66 |

67 | 68 |

All databases and tables

69 |
    70 | {% for permission in all_permissions %} 71 |
  • - {{ permission.description }}
  • 72 | {% endfor %} 73 |
74 | 75 | {% for database in database_with_tables %} 76 |

All tables in "{{ database.name }}"

77 |
    78 | {% for permission in database_permissions %} 79 |
  • - {{ permission.description }}
  • 80 | {% endfor %} 81 |
82 | {% endfor %} 83 | {% if databases_with_at_least_one_table %} 84 |

Specific tables in specific databases

85 | {% for database in databases_with_at_least_one_table %} 86 | {% for table in database.tables %} 87 |

{{ database.name }}: {{ table.name }}

88 |
    89 | {% for permission in resource_permissions %} 90 |
  • - {{ permission.description }}
  • 91 | {% endfor %} 92 |
93 | {% endfor %} 94 | {% endfor %} 95 | {% endif %} 96 | 97 |
98 | 99 | 176 | 177 | {% endblock %} 178 | -------------------------------------------------------------------------------- /datasette_auth_tokens/templates/token_details.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}API token{% if token.description %}: {{ token.description }}{% endif %}{% endblock %} 4 | 5 | {% block extra_head %} 6 | 14 | {% endblock %} 15 | 16 | {% block content %} 17 | 18 |

API token: {{ token.id }}

19 |

List all tokens

20 |
21 | {% if token.description %} 22 |
Description
23 |
{{ token.description }}
24 | {% endif %} 25 |
Token status
26 |
{{ token_status }}
27 |
Actor
28 |
{% if actor_display %}{{ actor_display }} ({{ token.actor_id }}){% else %}{{ token.actor_id }}{% endif %}
29 |
Created
30 |
{{ timestamp(token.created_timestamp) or "None" }}
31 |
Last used
32 |
{{ timestamp(token.last_used_timestamp) or "None" }}
33 | {% if token.expires_after_seconds %}
Expires at
34 |
{{ timestamp(token.created_timestamp + token.expires_after_seconds) }}
{% endif %} 35 |
Restrictions
36 |
{{ restrictions }}
37 |
38 | 39 | {% if token_status == "Active" and can_revoke %} 40 |
41 |
42 |

43 | 44 | 45 |

46 |
47 | {% endif %} 48 | 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /datasette_auth_tokens/templates/tokens_index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}API tokens{% endblock %} 4 | 5 | {% block extra_head %} 6 | 20 | {% endblock %} 21 | 22 | {% block content %} 23 | 24 |

API tokens

25 | 26 | {% if can_create_tokens %} 27 |

Create an API token

28 | {% else %} 29 |

You do not have permission to create API tokens.

30 | {% endif %} 31 | 32 | {% if tokens %} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {% for token in tokens %} 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | {% endfor %} 54 |
TokenActorRestrictionsCreatedLast usedExpires atEnded
{{ token.id }} - {{ token.status }}{% if token.description %}
{{ token.description }}{% endif %}
{% if token.actor_display %}{{ token.actor_display }} ({{ token.actor_id }}){% else %}{{ token.actor_id }}{% endif %}{{ format_permissions(token.permissions) }}{{ timestamp(token.created_timestamp) }}
{{ ago_difference(token.created_timestamp) }}
{{ timestamp(token.last_used_timestamp) }}
{{ ago_difference(token.last_used_timestamp) }}
{% if token.expires_after_seconds %}{{ timestamp(token.created_timestamp + token.expires_after_seconds) }}
{{ ago_difference(token.created_timestamp + token.expires_after_seconds) }}{% endif %}
{{ timestamp(token.ended_timestamp) }}
{{ ago_difference(token.ended_timestamp) }}
55 | {% endif %} 56 | 57 | {% if next %} 58 |

Next page

59 | {% endif %} 60 | {% if not is_first_page %} 61 |

First page

62 | {% endif %} 63 | 64 | {% endblock %} 65 | -------------------------------------------------------------------------------- /datasette_auth_tokens/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | import time 3 | 4 | 5 | def pluralize(n, unit): 6 | return f"{n} {unit}" if n == 1 else f"{n} {unit}s" 7 | 8 | 9 | def ago_difference(time1: int, time2: Optional[int] = None): 10 | if time1 is None: 11 | return "" 12 | if time2 is None: 13 | time2 = int(time.time()) 14 | delta = time1 - time2 15 | future = True 16 | if delta < 0: 17 | future = False 18 | delta = time2 - time1 19 | 20 | days, remainder = divmod(delta, 86400) 21 | hours, remainder = divmod(remainder, 3600) 22 | minutes, seconds = divmod(remainder, 60) 23 | 24 | days = int(days) 25 | hours = int(hours) 26 | minutes = int(minutes) 27 | seconds = int(seconds) 28 | parts = [] 29 | if days > 0: 30 | parts.append(pluralize(days, "day")) 31 | if hours > 0: 32 | parts.append(pluralize(hours, "hour")) 33 | if minutes > 0: 34 | parts.append(pluralize(minutes, "min")) 35 | if hours == 0 and seconds > 0: 36 | parts.append(pluralize(seconds, "sec")) 37 | 38 | combined = " ".join(parts) 39 | if not combined.strip(): 40 | return "" 41 | if future: 42 | return "In {}".format(combined) 43 | else: 44 | return "{} ago".format(combined) 45 | 46 | 47 | def format_permissions(datasette, permissions_dict): 48 | if not permissions_dict: 49 | return "All permissions" 50 | abbreviations = {} 51 | for permission in datasette.permissions.values(): 52 | if permission.abbr: 53 | abbreviations[permission.abbr] = permission.name 54 | 55 | output = [] 56 | 57 | # Format permissions for all databases 58 | if "a" in permissions_dict: 59 | output.append("All databases:") 60 | for code in permissions_dict["a"]: 61 | output.append(f"- {abbreviations.get(code, code)}") 62 | 63 | # Format permissions for specific databases 64 | if "d" in permissions_dict: 65 | for db, codes in permissions_dict["d"].items(): 66 | output.append(f"Database: {db}") 67 | for code in codes: 68 | output.append(f"- {abbreviations.get(code, code)}") 69 | 70 | # Format permissions for specific tables in specific databases 71 | if "r" in permissions_dict: 72 | for db, tables in permissions_dict["r"].items(): 73 | for table, codes in tables.items(): 74 | output.append(f"Table: {db}/{table}") 75 | for code in codes: 76 | output.append(f"- {abbreviations.get(code, code)}") 77 | 78 | return "\n".join(output) 79 | -------------------------------------------------------------------------------- /datasette_auth_tokens/views.py: -------------------------------------------------------------------------------- 1 | from datasette import Forbidden, Response, NotFound 2 | from datasette.utils import ( 3 | tilde_encode, 4 | tilde_decode, 5 | display_actor, 6 | ) 7 | from .utils import ago_difference, format_permissions 8 | import datetime 9 | import json 10 | import time 11 | 12 | TOKEN_PAGE_SIZE = 30 13 | 14 | 15 | async def create_api_token(request, datasette): 16 | await check_permission(datasette, request.actor) 17 | if request.method == "GET": 18 | return Response.html( 19 | await datasette.render_template( 20 | "create_api_token.html", 21 | await _shared(datasette, request), 22 | request=request, 23 | ) 24 | ) 25 | elif request.method == "POST": 26 | post = await request.post_vars() 27 | errors = [] 28 | expires_after = None 29 | if post.get("expire_type"): 30 | duration_string = post.get("expire_duration") 31 | if ( 32 | not duration_string 33 | or not duration_string.isdigit() 34 | or not int(duration_string) > 0 35 | ): 36 | errors.append("Invalid expire duration") 37 | else: 38 | unit = post["expire_type"] 39 | if unit == "minutes": 40 | expires_after = int(duration_string) * 60 41 | elif unit == "hours": 42 | expires_after = int(duration_string) * 60 * 60 43 | elif unit == "days": 44 | expires_after = int(duration_string) * 60 * 60 * 24 45 | else: 46 | errors.append("Invalid expire duration unit") 47 | 48 | # Are there any restrictions? 49 | restrict_all = [] 50 | restrict_database = {} 51 | restrict_resource = {} 52 | 53 | for key in post: 54 | if key.startswith("all:") and key.count(":") == 1: 55 | restrict_all.append(key.split(":")[1]) 56 | elif key.startswith("database:") and key.count(":") == 2: 57 | bits = key.split(":") 58 | database = tilde_decode(bits[1]) 59 | action = bits[2] 60 | restrict_database.setdefault(database, []).append(action) 61 | elif key.startswith("resource:") and key.count(":") == 3: 62 | bits = key.split(":") 63 | database = tilde_decode(bits[1]) 64 | resource = tilde_decode(bits[2]) 65 | action = bits[3] 66 | restrict_resource.setdefault(database, {}).setdefault( 67 | resource, [] 68 | ).append(action) 69 | 70 | # Reuse Datasette signed tokens mechanism to create parts of the token 71 | throwaway_signed_token = datasette.create_token( 72 | request.actor["id"], 73 | expires_after=expires_after, 74 | restrict_all=restrict_all, 75 | restrict_database=restrict_database, 76 | restrict_resource=restrict_resource, 77 | ) 78 | token_bits = datasette.unsign( 79 | throwaway_signed_token[len("dstok_") :], namespace="token" 80 | ) 81 | permissions = token_bits.get("_r") or None 82 | 83 | config = Config(datasette) 84 | db = config.db 85 | cursor = await db.execute_write( 86 | """ 87 | insert into _datasette_auth_tokens 88 | (secret_version, description, permissions, actor_id, created_timestamp, expires_after_seconds) 89 | values 90 | (:secret_version, :description, :permissions, :actor_id, :created_timestamp, :expires_after_seconds) 91 | """, 92 | { 93 | "secret_version": 0, 94 | "permissions": json.dumps(permissions), 95 | "description": post.get("description") or None, 96 | "actor_id": request.actor["id"], 97 | "created_timestamp": int(time.time()), 98 | "expires_after_seconds": expires_after, 99 | }, 100 | ) 101 | token = "dsatok_{}".format(datasette.sign(cursor.lastrowid, "dsatok")) 102 | 103 | context = await _shared(datasette, request) 104 | context.update({"errors": errors, "token": token, "token_bits": token_bits}) 105 | return Response.html( 106 | await datasette.render_template( 107 | "create_api_token.html", context, request=request 108 | ) 109 | ) 110 | else: 111 | raise Forbidden("Invalid method") 112 | 113 | 114 | async def check_permission(datasette, actor): 115 | if not actor or not actor.get("id"): 116 | raise Forbidden( 117 | "You must be logged in as an actor with an ID to create a token" 118 | ) 119 | if not await datasette.permission_allowed(actor, "auth-tokens-create"): 120 | raise Forbidden("You do not have permission to create a token") 121 | 122 | 123 | async def _shared(datasette, request): 124 | await check_permission(datasette, request.actor) 125 | db = Config(datasette).db 126 | 127 | tokens_exist = bool( 128 | (await db.execute("select 1 from _datasette_auth_tokens limit 1")).first() 129 | ) 130 | # Build list of databases and tables the user has permission to view 131 | database_with_tables = [] 132 | databases_with_at_least_one_table = [] 133 | for database in datasette.databases.values(): 134 | if database.name in ("_internal", "_memory"): 135 | continue 136 | if not await datasette.permission_allowed( 137 | request.actor, "view-database", database.name 138 | ): 139 | continue 140 | hidden_tables = await database.hidden_table_names() 141 | tables = [] 142 | for table in await database.table_names(): 143 | if table in hidden_tables: 144 | continue 145 | if not await datasette.permission_allowed( 146 | request.actor, 147 | "view-table", 148 | resource=(database.name, table), 149 | ): 150 | continue 151 | tables.append({"name": table, "encoded": tilde_encode(table)}) 152 | 153 | db_info = { 154 | "name": database.name, 155 | "encoded": tilde_encode(database.name), 156 | "tables": tables, 157 | } 158 | database_with_tables.append(db_info) 159 | if tables: 160 | databases_with_at_least_one_table.append(db_info) 161 | return { 162 | "actor": request.actor, 163 | "all_permissions": [ 164 | {"name": key, "description": value.description} 165 | for key, value in datasette.permissions.items() 166 | if key 167 | not in ( 168 | "auth-tokens-create", 169 | "auth-tokens-revoke-all", 170 | "debug-menu", 171 | "permissions-debug", 172 | ) 173 | ], 174 | "database_permissions": [ 175 | {"name": key, "description": value.description} 176 | for key, value in datasette.permissions.items() 177 | if value.takes_database 178 | ], 179 | "resource_permissions": [ 180 | {"name": key, "description": value.description} 181 | for key, value in datasette.permissions.items() 182 | if value.takes_resource 183 | ], 184 | "database_with_tables": database_with_tables, 185 | "databases_with_at_least_one_table": databases_with_at_least_one_table, 186 | "tokens_exist": tokens_exist, 187 | } 188 | 189 | 190 | async def tokens_index(datasette, request): 191 | from . import TOKEN_STATUSES, make_expire_function 192 | 193 | db = Config(datasette).db 194 | 195 | # Expire any tokens that are due for expiring 196 | await db.execute_write_fn(make_expire_function()) 197 | 198 | next = request.args.get("next") 199 | 200 | where_bits = [] 201 | params = {} 202 | if next: 203 | where_bits.append("id <= :next") 204 | params["next"] = next 205 | where = " and ".join(where_bits) 206 | 207 | # Users can only see their own tokens, unless they have the 208 | # auth-tokens-view-all permission 209 | if not await datasette.permission_allowed(request.actor, "auth-tokens-view-all"): 210 | where_bits.append("actor_id = :actor_id") 211 | params["actor_id"] = request.actor["id"] if request.actor else None 212 | 213 | tokens = [ 214 | dict(row) 215 | for row in ( 216 | await db.execute( 217 | """ 218 | select * from _datasette_auth_tokens 219 | {where} order by id desc limit {limit} 220 | """.format( 221 | where="where {}".format(where) if where else "", 222 | limit=TOKEN_PAGE_SIZE + 1, 223 | ), 224 | params, 225 | ) 226 | ).rows 227 | ] 228 | next = None 229 | if len(tokens) == TOKEN_PAGE_SIZE + 1: 230 | next = tokens[-1]["id"] 231 | tokens = tokens[:-1] 232 | 233 | for token in tokens: 234 | token["status"] = TOKEN_STATUSES.get( 235 | token["token_status"], token["token_status"] 236 | ) 237 | 238 | # Resolve actors 239 | actor_ids = set([token["actor_id"] for token in tokens]) 240 | actors = await datasette.actors_from_ids(list(actor_ids)) 241 | for token in tokens: 242 | actor = actors.get(token["actor_id"]) 243 | token["actor"] = actor 244 | token["actor_display"] = display_actor(actor) if actor else None 245 | 246 | def _format_permissions(json_string): 247 | return format_permissions(datasette, json.loads(json_string)) 248 | 249 | return Response.html( 250 | await datasette.render_template( 251 | "tokens_index.html", 252 | { 253 | "tokens": tokens, 254 | "next": next, 255 | "is_first_page": not bool(request.args.get("next")), 256 | "timestamp": _timestamp, 257 | "ago_difference": ago_difference, 258 | "format_permissions": _format_permissions, 259 | "can_create_tokens": await datasette.permission_allowed( 260 | request.actor, "auth-tokens-create" 261 | ), 262 | }, 263 | request=request, 264 | ) 265 | ) 266 | 267 | 268 | async def token_details(request, datasette): 269 | from . import TOKEN_STATUSES 270 | 271 | config = Config(datasette) 272 | db = config.db 273 | 274 | id = request.url_vars["id"] 275 | 276 | async def fetch_row(): 277 | return ( 278 | await db.execute("select * from _datasette_auth_tokens where id = ?", (id,)) 279 | ).first() 280 | 281 | row = await fetch_row() 282 | if row is None: 283 | raise NotFound("Token not found") 284 | 285 | # User can manage if they own the token or they have auth-tokens-revoke-all 286 | if not await actor_can_view(datasette, request.actor, row["actor_id"]): 287 | raise Forbidden("You do not have permission to manage this token") 288 | 289 | can_revoke = await actor_can_revoke(datasette, request.actor, row["actor_id"]) 290 | 291 | if ( 292 | row["expires_after_seconds"] 293 | and (row["created_timestamp"] + row["expires_after_seconds"]) < time.time() 294 | ): 295 | await db.execute_write( 296 | "update _datasette_auth_tokens set token_status='E' where id=:token_id", 297 | {"token_id": id}, 298 | ) 299 | row = await fetch_row() 300 | 301 | if request.method == "POST": 302 | post_vars = await request.post_vars() 303 | if post_vars.get("revoke"): 304 | if not can_revoke: 305 | raise Forbidden("You do not have permission to revoke this token") 306 | else: 307 | await db.execute_write( 308 | """ 309 | update _datasette_auth_tokens 310 | set 311 | token_status = 'R', 312 | ended_timestamp = :now 313 | where id = :id 314 | """, 315 | {"id": id, "now": int(time.time())}, 316 | ) 317 | return Response.redirect(request.path) 318 | 319 | restrictions = "None" 320 | permissions = json.loads(row["permissions"]) 321 | if permissions: 322 | restrictions = format_permissions(datasette, permissions) 323 | 324 | actors = await datasette.actors_from_ids([row["actor_id"]]) 325 | actor_display = None 326 | if actors and actors.get(row["actor_id"]): 327 | actor_display = display_actor(actors[row["actor_id"]]) 328 | 329 | return Response.html( 330 | await datasette.render_template( 331 | "token_details.html", 332 | { 333 | "token": row, 334 | "actor_display": actor_display, 335 | "token_status": TOKEN_STATUSES.get( 336 | row["token_status"], row["token_status"] 337 | ), 338 | "timestamp": _timestamp, 339 | "ago_difference": ago_difference, 340 | "restrictions": restrictions, 341 | "can_revoke": can_revoke, 342 | }, 343 | request=request, 344 | ) 345 | ) 346 | 347 | 348 | def _timestamp(ts): 349 | if ts: 350 | return datetime.datetime.fromtimestamp(ts).isoformat() 351 | else: 352 | return "" 353 | 354 | 355 | async def actor_can_view(datasette, actor, token_actor_id): 356 | if not actor or not actor.get("id"): 357 | # Only works for actors that have an ID set 358 | return False 359 | if token_actor_id and str(token_actor_id) == str(actor.get("id")): 360 | return True 361 | # User with auth-tokens-view-all can view any token 362 | return await datasette.permission_allowed(actor, "auth-tokens-view-all") 363 | 364 | 365 | async def actor_can_revoke(datasette, actor, token_actor_id): 366 | if not actor or not actor.get("id"): 367 | # Only works for actors that have an ID set 368 | return False 369 | if token_actor_id and str(token_actor_id) == str(actor.get("id")): 370 | return True 371 | # User with auth-tokens-revoke-all can revoke any token 372 | return await datasette.permission_allowed(actor, "auth-tokens-revoke-all") 373 | 374 | 375 | class Config: 376 | def __init__(self, datasette): 377 | self._plugin_config = datasette.plugin_config("datasette-auth-tokens") or {} 378 | self._datasette = datasette 379 | self.enabled = self._plugin_config.get("manage_tokens") 380 | 381 | def get(self, key): 382 | return self._plugin_config.get(key) 383 | 384 | @property 385 | def db(self): 386 | db_name = self._plugin_config.get("manage_tokens_database") or None 387 | if db_name is None: 388 | return self._datasette.get_internal_database() 389 | else: 390 | return self._datasette.get_database(db_name) 391 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | VERSION = "0.4a10" 5 | 6 | 7 | def get_long_description(): 8 | with open( 9 | os.path.join(os.path.dirname(os.path.abspath(__file__)), "README.md"), 10 | encoding="utf8", 11 | ) as fp: 12 | return fp.read() 13 | 14 | 15 | setup( 16 | name="datasette-auth-tokens", 17 | description="Datasette plugin for authenticating access using API tokens", 18 | long_description=get_long_description(), 19 | long_description_content_type="text/markdown", 20 | author="Simon Willison", 21 | url="https://github.com/simonw/datasette-auth-tokens", 22 | project_urls={ 23 | "Issues": "https://github.com/simonw/datasette-auth-tokens/issues", 24 | "CI": "https://github.com/simonw/datasette-auth-tokens/actions", 25 | "Changelog": "https://github.com/simonw/datasette-auth-tokens/releases", 26 | }, 27 | license="Apache License, Version 2.0", 28 | version=VERSION, 29 | packages=["datasette_auth_tokens"], 30 | entry_points={"datasette": ["auth_tokens = datasette_auth_tokens"]}, 31 | install_requires=[ 32 | "datasette>=1.0a8", 33 | "sqlite-utils", 34 | "sqlite-migrate", 35 | ], 36 | extras_require={ 37 | "test": ["pytest", "pytest-asyncio", "httpx", "sqlite-utils", "datasette-test"] 38 | }, 39 | package_data={"datasette_auth_tokens": ["templates/*.html"]}, 40 | ) 41 | -------------------------------------------------------------------------------- /tests/test_auth_tokens.py: -------------------------------------------------------------------------------- 1 | from datasette_test import Datasette 2 | import pytest 3 | import pytest_asyncio 4 | import sqlite_utils 5 | 6 | 7 | @pytest_asyncio.fixture 8 | async def ds(tmp_path_factory): 9 | db_directory = tmp_path_factory.mktemp("dbs") 10 | db_path1 = db_directory / "demo.db" 11 | sqlite_utils.Database(db_path1)["foo"].insert({"bar": 1}) 12 | db_path2 = db_directory / "tokens.db" 13 | db = sqlite_utils.Database(db_path2) 14 | db["tokens"].insert_all( 15 | [ 16 | { 17 | "id": 1, 18 | "actor_id": "one", 19 | "actor_name": "Cleo", 20 | "token_secret": "oneone", 21 | }, 22 | { 23 | "id": 2, 24 | "actor_id": "two", 25 | "actor_name": "Pancakes", 26 | "token_secret": "twotwo", 27 | }, 28 | ], 29 | pk="id", 30 | ) 31 | return Datasette( 32 | [db_path1, db_path2], 33 | plugin_config={ 34 | "datasette-auth-tokens": { 35 | "query": { 36 | "sql": ( 37 | "select actor_id, actor_name, token_secret " 38 | "from tokens where id = :token_id" 39 | ), 40 | "database": "tokens", 41 | }, 42 | "tokens": [ 43 | {"token": "one", "actor": {"id": "one"}}, 44 | {"token": "two", "actor": {"id": "two"}}, 45 | ], 46 | "param": "_auth_token", 47 | } 48 | }, 49 | config={ 50 | "databases": { 51 | "demo": {"allow_sql": {"id": "one"}}, 52 | "tokens": {"allow": {}}, 53 | }, 54 | }, 55 | ) 56 | 57 | 58 | @pytest.mark.parametrize( 59 | "token,path,expected_status", 60 | [ 61 | ("", "/", 200), 62 | ("", "/demo?sql=select+1", 403), 63 | ("one", "/", 200), 64 | ("one", "/demo?sql=select+1", 200), 65 | ("two", "/", 200), 66 | ("two", "/demo?sql=select+1", 403), 67 | ], 68 | ) 69 | @pytest.mark.asyncio 70 | async def test_token(ds, token, path, expected_status): 71 | response = await ds.client.get( 72 | path, 73 | headers={"Authorization": "Bearer {}".format(token)}, 74 | follow_redirects=True, 75 | ) 76 | assert response.status_code == expected_status 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "token,path,expected_status", 81 | [ 82 | ("", "/?", 200), 83 | ("", "/demo?sql=select+1", 403), 84 | ("one", "/?", 200), 85 | ("one", "/demo?sql=select+1", 200), 86 | ("two", "/?", 200), 87 | ("two", "/demo?sql=select+1", 403), 88 | ], 89 | ) 90 | @pytest.mark.asyncio 91 | async def test_query_param(ds, token, path, expected_status): 92 | response = await ds.client.get( 93 | "{}&_auth_token={}".format(path, token), follow_redirects=True 94 | ) 95 | assert response.status_code == expected_status 96 | 97 | 98 | @pytest.mark.parametrize( 99 | "token,path,expected_status", 100 | [ 101 | ("", "/", 200), 102 | ("", "/demo?sql=select+1", 403), 103 | ("1-oneone", "/", 200), 104 | ("1-oneone", "/demo?sql=select+1", 200), 105 | ("2-twotwo", "/", 200), 106 | ("2-twotwo", "/demo?sql=select+1", 403), 107 | ("invalid", "/", 200), 108 | ("invalid", "/demo?sql=select+1", 403), 109 | ], 110 | ) 111 | @pytest.mark.asyncio 112 | async def test_query(ds, token, path, expected_status): 113 | response = await ds.client.get( 114 | path, 115 | headers={"Authorization": "Bearer {}".format(token)}, 116 | follow_redirects=True, 117 | ) 118 | assert response.status_code == expected_status 119 | 120 | 121 | @pytest.mark.parametrize( 122 | "token,expected_actor", 123 | [ 124 | ("1-oneone", {"id": "one", "name": "Cleo"}), 125 | ("2-twotwo", {"id": "two", "name": "Pancakes"}), 126 | ("invalid", None), 127 | ("invalid", None), 128 | ], 129 | ) 130 | @pytest.mark.asyncio 131 | async def test_actor(ds, token, expected_actor): 132 | response = await ds.client.get( 133 | "/-/actor.json", 134 | headers={"Authorization": "Bearer {}".format(token)}, 135 | follow_redirects=True, 136 | ) 137 | assert response.json() == {"actor": expected_actor} 138 | 139 | 140 | @pytest.mark.parametrize( 141 | "path", 142 | [ 143 | "/tokens", 144 | "/tokens/tokens", 145 | "/tokens?sql=select+*+from+tokens", 146 | ], 147 | ) 148 | @pytest.mark.asyncio 149 | async def test_tokens_table_not_visible(ds, path): 150 | response = await ds.client.get(path) 151 | assert response.status_code == 403 152 | -------------------------------------------------------------------------------- /tests/test_managed_tokens.py: -------------------------------------------------------------------------------- 1 | from datasette_test import Datasette 2 | from datasette.plugins import pm 3 | from datasette import hookimpl 4 | import pytest 5 | import pytest_asyncio 6 | import sqlite_utils 7 | import time 8 | 9 | 10 | class ActorsPlugin: 11 | __name__ = "ActorsPlugin" 12 | 13 | @hookimpl 14 | def actors_from_ids(self, datasette): 15 | return getattr(datasette, "_test_actors", {}) 16 | 17 | 18 | pm.register(ActorsPlugin(), name="undo_actors_plugin") 19 | 20 | 21 | @pytest.fixture 22 | def db_path(tmp_path_factory): 23 | db_directory = tmp_path_factory.mktemp("dbs") 24 | db_path = db_directory / "demo.db" 25 | sqlite_utils.Database(db_path)["foo"].insert({"bar": 1}) 26 | return db_path 27 | 28 | 29 | @pytest_asyncio.fixture 30 | async def ds_managed(db_path): 31 | return Datasette( 32 | [db_path], 33 | plugin_config={ 34 | "datasette-auth-tokens": { 35 | "manage_tokens": True, 36 | "param": "_auth_token", 37 | } 38 | }, 39 | config={ 40 | "permissions": { 41 | "auth-tokens-revoke-all": {"id": "admin"}, 42 | "auth-tokens-view-all": {"id": "admin"}, 43 | "auth-tokens-create": {"id": "*"}, 44 | }, 45 | }, 46 | ) 47 | 48 | 49 | @pytest_asyncio.fixture 50 | async def ds_managed_is_member(db_path): 51 | class IsMemberPlugin: 52 | __name__ = "IsMemberPlugin" 53 | 54 | @hookimpl 55 | def permission_allowed(self, datasette, actor, action): 56 | if action == "auth-tokens-create": 57 | return actor.get("is_member", False) 58 | 59 | pm.register(IsMemberPlugin(), name="undo_is_member_plugin") 60 | try: 61 | yield Datasette( 62 | [db_path], 63 | plugin_config={ 64 | "datasette-auth-tokens": { 65 | "manage_tokens": True, 66 | "param": "_auth_token", 67 | } 68 | }, 69 | ) 70 | finally: 71 | pm.unregister(name="undo_is_member_plugin") 72 | 73 | 74 | # Alternative database fixture 75 | @pytest_asyncio.fixture 76 | async def ds_api_db(tmp_path_factory): 77 | db_directory = tmp_path_factory.mktemp("dbs") 78 | db_path = db_directory / "demo.db" 79 | sqlite_utils.Database(db_path)["foo"].insert({"bar": 1}) 80 | api_db_path = db_directory / "api.db" 81 | sqlite_utils.Database(api_db_path)["comment"].insert({"this-is-for-tokens": 1}) 82 | return Datasette( 83 | [db_path, api_db_path], 84 | plugin_config={ 85 | "datasette-auth-tokens": { 86 | "manage_tokens": True, 87 | "param": "_auth_token", 88 | "manage_tokens_database": "api", 89 | } 90 | }, 91 | config={ 92 | "permissions": { 93 | "auth-tokens-create": {"id": "*"}, 94 | }, 95 | }, 96 | ) 97 | 98 | 99 | @pytest.mark.parametrize("status", ("active", "revoked", "expired", "invalid")) 100 | @pytest.mark.parametrize("database", (None, "api")) 101 | @pytest.mark.asyncio 102 | async def test_active_revoked_expired_tokens(ds_managed, ds_api_db, status, database): 103 | if database is not None: 104 | ds_managed = ds_api_db 105 | db = ds_managed.get_database(database) 106 | else: 107 | db = ds_managed.get_internal_database() 108 | 109 | token_id, token = await _create_token(ds_managed) 110 | expected_actor = {"id": "root", "token": "dsatok", "token_id": token_id} 111 | if status in ("revoked", "expired"): 112 | expected_actor = None 113 | if status == "revoked": 114 | await db.execute_write( 115 | "update _datasette_auth_tokens set token_status = 'R' where id=:id", 116 | {"id": token_id}, 117 | ) 118 | elif status == "expired": 119 | # Expire it by setting the created_timestamp and expires_after_seconds 120 | await db.execute_write( 121 | "update _datasette_auth_tokens set created_timestamp = :created, expires_after_seconds = 60 where id=:id", 122 | {"id": token_id, "created": time.time() - 120}, 123 | ) 124 | elif status == "invalid": 125 | token = "dsatok_bad-token" 126 | expected_actor = None 127 | actor_response = await ds_managed.client.get( 128 | "/-/actor.json", headers={"Authorization": "Bearer {}".format(token)} 129 | ) 130 | assert actor_response.status_code == 200 131 | assert actor_response.json() == {"actor": expected_actor} 132 | 133 | 134 | async def _create_token(ds_managed, actor_id="root"): 135 | root_cookie = ds_managed.client.actor_cookie({"id": actor_id}) 136 | create_page = await ds_managed.client.get( 137 | "/-/api/tokens/create", cookies={"ds_actor": root_cookie} 138 | ) 139 | ds_csrftoken = create_page.cookies["ds_csrftoken"] 140 | post_fields = {} 141 | post_fields["csrftoken"] = ds_csrftoken 142 | response = await ds_managed.client.post( 143 | "/-/api/tokens/create", 144 | data=post_fields, 145 | cookies={"ds_actor": root_cookie, "ds_csrftoken": ds_csrftoken}, 146 | ) 147 | assert response.status_code == 200 148 | api_token = response.text.split('class="copyable" style="width: 40%" value="')[ 149 | 1 150 | ].split('"')[0] 151 | # Decode token to find token ID 152 | token_id = ds_managed.unsign(api_token.split("dsatok_")[1], namespace="dsatok") 153 | return token_id, api_token 154 | 155 | 156 | @pytest.mark.parametrize( 157 | "post_fields,expected_actor", 158 | [ 159 | ({}, {"id": "root", "token": "dsatok"}), 160 | ( 161 | {"resource:demo:foo:view-table": "1"}, 162 | {"id": "root", "token": "dsatok", "_r": {"r": {"demo": {"foo": ["vt"]}}}}, 163 | ), 164 | ], 165 | ) 166 | @pytest.mark.parametrize("database", (None, "api")) 167 | @pytest.mark.parametrize("custom_actor_display", (False, True)) 168 | @pytest.mark.asyncio 169 | async def test_create_token( 170 | ds_managed, ds_api_db, post_fields, expected_actor, database, custom_actor_display 171 | ): 172 | if database is not None: 173 | ds_managed = ds_api_db 174 | 175 | if custom_actor_display: 176 | ds_managed._test_actors = { 177 | "root": { 178 | "id": "root", 179 | "name": "Root", 180 | }, 181 | "owner": { 182 | "id": "owner", 183 | "name": "Owner", 184 | }, 185 | } 186 | else: 187 | ds_managed._test_actors = {} 188 | 189 | cookie = ds_managed.client.actor_cookie({"id": "root"}) 190 | # Load initial create token page 191 | create_page = await ds_managed.client.get( 192 | "/-/api/tokens/create", cookies={"ds_actor": cookie} 193 | ) 194 | assert create_page.status_code == 200 195 | # Extract ds_csrftoken 196 | ds_csrftoken = create_page.cookies["ds_csrftoken"] 197 | # Use that to create the token 198 | post_fields["csrftoken"] = ds_csrftoken 199 | response = await ds_managed.client.post( 200 | "/-/api/tokens/create", 201 | data=post_fields, 202 | cookies={"ds_actor": cookie, "ds_csrftoken": ds_csrftoken}, 203 | ) 204 | assert response.status_code == 200 205 | api_token = response.text.split('class="copyable" style="width: 40%" value="')[ 206 | 1 207 | ].split('"')[0] 208 | assert api_token 209 | # Now try using it to request /-/actor.json 210 | response = await ds_managed.client.get( 211 | "/-/actor.json", headers={"Authorization": "Bearer {}".format(api_token)} 212 | ) 213 | assert response.status_code == 200 214 | token_id = ds_managed.unsign(api_token.split("dsatok_")[1], namespace="dsatok") 215 | expected_actor["token_id"] = token_id 216 | assert response.json()["actor"] == expected_actor 217 | # Token should be visible in the HTML list 218 | response = await ds_managed.client.get( 219 | "/-/api/tokens", cookies={"ds_actor": cookie} 220 | ) 221 | assert response.status_code == 200 222 | assert f'1 - Active' in response.text 223 | if custom_actor_display: 224 | assert "Root (root)" in response.text 225 | else: 226 | assert "root" in response.text 227 | # And should have its own page 228 | token_details = await ds_managed.client.get( 229 | f"/-/api/tokens/{token_id}", cookies={"ds_actor": cookie} 230 | ) 231 | assert token_details.status_code == 200 232 | if custom_actor_display: 233 | assert "
Root (root)
" in token_details.text 234 | else: 235 | assert "
root
" in token_details.text 236 | 237 | 238 | @pytest.mark.asyncio 239 | @pytest.mark.parametrize("is_member", (False, True)) 240 | async def test_create_token_permissions(ds_managed_is_member, is_member): 241 | actor = {"id": "root", "is_member": is_member} 242 | cookies = {"ds_actor": ds_managed_is_member.client.actor_cookie(actor)} 243 | # tokens/create link should only show for users with permission to create tokens 244 | list_page = await ds_managed_is_member.client.get("/-/api/tokens", cookies=cookies) 245 | if is_member: 246 | assert 'href="tokens/create"' in list_page.text 247 | else: 248 | assert 'href="tokens/create"' not in list_page.text 249 | # We always get CSRF token from /-/permissions 250 | csrftoken = ( 251 | await ds_managed_is_member.client.get("/-/permissions", cookies=cookies) 252 | ).cookies["ds_csrftoken"] 253 | cookies["ds_csrftoken"] = csrftoken 254 | create_page = await ds_managed_is_member.client.get( 255 | "/-/api/tokens/create", cookies=cookies 256 | ) 257 | if is_member: 258 | assert create_page.status_code == 200 259 | else: 260 | assert create_page.status_code == 403 261 | # Now try a POST to create a token 262 | response = await ds_managed_is_member.client.post( 263 | "/-/api/tokens/create", 264 | data={"csrftoken": csrftoken}, 265 | cookies=cookies, 266 | ) 267 | if is_member: 268 | assert response.status_code == 200 269 | else: 270 | assert response.status_code == 403 271 | 272 | 273 | @pytest.mark.asyncio 274 | @pytest.mark.parametrize( 275 | "scenario,should_allow_view,should_allow_revoke", 276 | ( 277 | ("owner", True, True), 278 | ("admin", True, True), 279 | ("other-user", False, False), 280 | ("anonymous", False, False), 281 | ), 282 | ) 283 | async def test_token_permissions( 284 | ds_managed, scenario, should_allow_view, should_allow_revoke 285 | ): 286 | # Create a token 287 | token_id, _ = await _create_token(ds_managed, "owner") 288 | 289 | async def get_token(token_id): 290 | return ( 291 | await ds_managed.get_internal_database().execute( 292 | "select * from _datasette_auth_tokens where id=:id", 293 | {"id": token_id}, 294 | ) 295 | ).first() 296 | 297 | assert (await get_token(token_id))["ended_timestamp"] is None 298 | 299 | if scenario != "anonymous": 300 | cookies = {"ds_actor": ds_managed.client.actor_cookie({"id": scenario})} 301 | else: 302 | cookies = {} 303 | 304 | # Get the token details page 305 | response = await ds_managed.client.get( 306 | "/-/api/tokens/{}".format(token_id), cookies=cookies 307 | ) 308 | 309 | csrftoken = "-" 310 | 311 | if not should_allow_view: 312 | assert response.status_code == 403 313 | else: 314 | assert response.status_code == 200 315 | csrftoken = response.cookies["ds_csrftoken"] 316 | cookies["ds_csrftoken"] = csrftoken 317 | # Is the revoke button present? 318 | if should_allow_revoke: 319 | assert 'name="revoke"' in response.text 320 | else: 321 | assert 'name="revoke"' not in response.text 322 | 323 | # Now try to revoke it 324 | revoke_response = await ds_managed.client.post( 325 | "/-/api/tokens/{}".format(token_id), 326 | data={"revoke": "1", "csrftoken": csrftoken}, 327 | cookies=cookies, 328 | ) 329 | 330 | if should_allow_revoke: 331 | assert revoke_response.status_code == 302 332 | # Check token was revoked in the database 333 | token = await get_token(token_id) 334 | assert token["token_status"] == "R" 335 | assert token["ended_timestamp"] 336 | else: 337 | assert revoke_response.status_code == 403 338 | 339 | 340 | @pytest.mark.asyncio 341 | async def test_viewing_tokens_expires_some(ds_managed): 342 | # Viewing the /-/api/tokens page should expire any tokens that need it 343 | db = ds_managed.get_internal_database() 344 | token_id, _ = await _create_token(ds_managed) 345 | await db.execute_write( 346 | "update _datasette_auth_tokens set created_timestamp = :created, expires_after_seconds = 60 where id=:id", 347 | {"id": token_id, "created": time.time() - 120}, 348 | ) 349 | 350 | async def get_token(): 351 | return ( 352 | await db.execute( 353 | "select * from _datasette_auth_tokens where id=:token_id", 354 | {"token_id": token_id}, 355 | ) 356 | ).first() 357 | 358 | token = await get_token() 359 | assert token["token_status"] == "A" 360 | 361 | # Viewing the list of tokens should expire it 362 | response = await ds_managed.client.get( 363 | "/-/api/tokens", 364 | cookies={"ds_actor": ds_managed.client.actor_cookie({"id": "admin"})}, 365 | ) 366 | assert response.status_code == 200 367 | token = await get_token() 368 | assert token["token_status"] == "E" 369 | 370 | 371 | @pytest.mark.asyncio 372 | async def test_token_pagination(ds_managed): 373 | num_tokens = 100 374 | for i in range(num_tokens): 375 | await _create_token(ds_managed) 376 | cookies = {"ds_actor": ds_managed.client.actor_cookie({"id": "admin"})} 377 | collected = [] 378 | next_ = None 379 | pages = 0 380 | while True: 381 | path = "/-/api/tokens" 382 | if next_: 383 | path += "?next={}".format(next_) 384 | response = await ds_managed.client.get(path, cookies=cookies) 385 | pages += 1 386 | assert response.status_code == 200 387 | bits = response.text.split('')[0] 391 | new_token_ids.append(token_id) 392 | if '')[0] 394 | else: 395 | next_ = None 396 | # Protect against infinite loops 397 | if any(id in collected for id in new_token_ids): 398 | assert False, "Infinite loop detected" 399 | collected.extend(new_token_ids) 400 | if next_ is None: 401 | break 402 | assert len(set(collected)) == num_tokens 403 | assert pages > 1 404 | 405 | 406 | @pytest.mark.asyncio 407 | async def test_tokens_cannot_be_restricted_to_auth_tokens_revoke_all(ds_managed): 408 | root_cookie = ds_managed.client.actor_cookie({"id": "root"}) 409 | create_page = await ds_managed.client.get( 410 | "/-/api/tokens/create", cookies={"ds_actor": root_cookie} 411 | ) 412 | assert "auth-tokens-revoke-all" not in create_page.text 413 | 414 | 415 | @pytest.mark.asyncio 416 | @pytest.mark.parametrize("has_a_table", (True, False)) 417 | async def test_no_table_heading_if_no_tables(tmpdir, has_a_table): 418 | # https://github.com/simonw/datasette-auth-tokens/issues/32 419 | db_path = str(tmpdir / "empty.db") 420 | db = sqlite_utils.Database(db_path) 421 | db.vacuum() 422 | if has_a_table: 423 | db["foo"].insert({"bar": 1}) 424 | ds = Datasette( 425 | [db_path], 426 | plugin_config={"datasette-auth-tokens": {"manage_tokens": True}}, 427 | config={ 428 | "permissions": { 429 | "auth-tokens-create": {"id": "*"}, 430 | }, 431 | }, 432 | ) 433 | response = await ds.client.get( 434 | "/-/api/tokens/create", 435 | cookies={"ds_actor": ds.client.actor_cookie({"id": "admin"})}, 436 | ) 437 | assert response.status_code == 200 438 | fragment = ">Specific tables in specific databases<" 439 | if has_a_table: 440 | assert fragment in response.text 441 | else: 442 | assert fragment not in response.text 443 | -------------------------------------------------------------------------------- /tests/test_migrations.py: -------------------------------------------------------------------------------- 1 | from datasette_auth_tokens.migrations import migration 2 | import sqlite_utils 3 | 4 | OLD_CREATE_TABLES_SQL = """ 5 | CREATE TABLE _datasette_auth_tokens ( 6 | id INTEGER PRIMARY KEY, 7 | token_status TEXT DEFAULT 'L', -- [L]ive, [R]evoked, [E]xpired 8 | description TEXT, 9 | actor_id TEXT, 10 | permissions TEXT, 11 | created_timestamp INTEGER, 12 | last_used_timestamp INTEGER, 13 | expires_after_seconds INTEGER, 14 | secret_version INTEGER DEFAULT 0 15 | ); 16 | """ 17 | 18 | 19 | def test_migrate_from_original(): 20 | db = sqlite_utils.Database(memory=True) 21 | db.execute(OLD_CREATE_TABLES_SQL) 22 | assert db["_datasette_auth_tokens"].columns_dict == { 23 | "id": int, 24 | "token_status": str, 25 | "description": str, 26 | "actor_id": str, 27 | "permissions": str, 28 | "created_timestamp": int, 29 | "last_used_timestamp": int, 30 | "expires_after_seconds": int, 31 | "secret_version": int, 32 | } 33 | 34 | # Default token_status should be L 35 | def get_col(): 36 | return [ 37 | col 38 | for col in db["_datasette_auth_tokens"].columns 39 | if col.name == "token_status" 40 | ][0] 41 | 42 | assert get_col().default_value == "'L'" 43 | migration.apply(db) 44 | assert db["_datasette_auth_tokens"].columns_dict["ended_timestamp"] == int 45 | # Should have updated token default 46 | assert get_col().default_value == "'A'" 47 | # Confirm column order is correct 48 | column_order = [col.name for col in db["_datasette_auth_tokens"].columns] 49 | assert column_order == [ 50 | "id", 51 | "token_status", 52 | "description", 53 | "actor_id", 54 | "permissions", 55 | "created_timestamp", 56 | "last_used_timestamp", 57 | "expires_after_seconds", 58 | "ended_timestamp", 59 | "secret_version", 60 | ] 61 | --------------------------------------------------------------------------------