Mastodon Oauth
Build LinkLog Development Image / development-image (push) Successful in 10s

This commit is contained in:
Olaf
2026-08-25 23:31:49 +02:00
parent 102d8e533c
commit 57e9775882
12 changed files with 191 additions and 10 deletions
+2
View File
@@ -20,6 +20,8 @@ LINKLOG_SMTP_FROM=LinkLog <no-reply@localhost>
LINKLOG_SMTP_USE_TLS=true LINKLOG_SMTP_USE_TLS=true
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24 LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1 LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
LINKLOG_MASTODON_CLIENT_NAME=LinkLog
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES=10
# Optional comma-separated override. Leave empty to use the built-in list. # Optional comma-separated override. Leave empty to use the built-in list.
LINKLOG_TRACKING_PARAMS= LINKLOG_TRACKING_PARAMS=
# Keep the default path when using the named linklog_data volume. # Keep the default path when using the named linklog_data volume.
+3 -2
View File
@@ -226,12 +226,13 @@ For a real deployment, start with the docker-compose-example.yaml file. replace
## Mastodon Configuration ## Mastodon Configuration
After logging in, open <http://localhost:8000/profile> and save the Mastodon settings: After logging in, open <http://localhost:8000/profile>, enter the Mastodon instance, and select **Connect Mastodon**. LinkLog registers an OAuth application on that instance, opens Mastodon authorization, and stores the returned per-user access token after the callback. The requested scopes are `read:accounts` and `write:statuses`.
- **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social` - **Instance**: hostname or URL such as `mastodon.social` or `https://mastodon.social`
- **Access token**: a Mastodon API token with permission to create statuses
- **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: ` - **Post prefix**: text placed immediately before the link; defaults to `From my #LinkLog: `
`LINKLOG_PUBLIC_URL` must be the URL users can reach for the OAuth callback. For local development it can remain `http://localhost:8000`; for a deployment it must be the public LinkLog URL. Existing manually entered access tokens remain compatible with the plugin configuration API.
Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link. Enable the plugin from the admin API or the admin page. New links are saved first and then posted to the configured instance at `/api/v1/statuses`. A Mastodon network failure does not undo the saved link.
## Useful API Calls ## Useful API Calls
+33
View File
@@ -0,0 +1,33 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from starlette.requests import Request
from backend.app.api.dependencies import get_current_user
from backend.app.services.mastodon_oauth import finish_authorization, start_authorization
router = APIRouter()
@router.get('/oauth/start')
def oauth_start(instance: str = 'mastodon.social', user: dict = Depends(get_current_user)):
try:
authorization_url = start_authorization(user['id'], instance)
except Exception as error:
raise HTTPException(status_code=502, detail=f'Could not register with Mastodon: {error}') from error
return {'authorization_url': authorization_url}
@router.get('/oauth/callback')
def oauth_callback(request: Request, code: str | None = None, state: str | None = None, error: str | None = None):
if error or not code or not state:
return RedirectResponse(f'/profile?mastodon_error={quote(error or "Authorization was cancelled")}')
try:
finish_authorization(code, state)
except Exception as callback_error:
return RedirectResponse(f'/profile?mastodon_error={quote(str(callback_error))}')
return RedirectResponse('/profile?mastodon=connected')
+2
View File
@@ -26,6 +26,8 @@ class Settings:
smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'} smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'}
email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24')) email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24'))
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1')) password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
mastodon_client_name: str = os.getenv('LINKLOG_MASTODON_CLIENT_NAME', 'LinkLog')
mastodon_oauth_expiry_minutes: int = int(os.getenv('LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES', '10'))
tracking_params: list[str] = None tracking_params: list[str] = None
def __post_init__(self): def __post_init__(self):
+16
View File
@@ -135,6 +135,22 @@ CREATE TABLE IF NOT EXISTS password_reset_tokens (
); );
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id
ON password_reset_tokens(user_id); ON password_reset_tokens(user_id);
'''),
(8, '''
CREATE TABLE IF NOT EXISTS mastodon_oauth_states (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
state_hash TEXT NOT NULL UNIQUE,
instance TEXT NOT NULL,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_mastodon_oauth_states_state_hash
ON mastodon_oauth_states(state_hash);
'''), '''),
] ]
+2
View File
@@ -11,6 +11,7 @@ from starlette.requests import Request
from backend.app.api.admin import router as admin_router from backend.app.api.admin import router as admin_router
from backend.app.api.auth import router as auth_router from backend.app.api.auth import router as auth_router
from backend.app.api.links import router as links_router from backend.app.api.links import router as links_router
from backend.app.api.mastodon import router as mastodon_router
from backend.app.api.public import router as public_router from backend.app.api.public import router as public_router
from backend.app.api.setup import router as setup_router from backend.app.api.setup import router as setup_router
from backend.app.api.setup import has_administrator from backend.app.api.setup import has_administrator
@@ -24,6 +25,7 @@ app.mount('/static', StaticFiles(directory='frontend/static'), name='static')
app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media') app.mount('/media', StaticFiles(directory=AVATARS_DIR), name='media')
app.include_router(auth_router, prefix='/api/auth') app.include_router(auth_router, prefix='/api/auth')
app.include_router(links_router, prefix='/api') app.include_router(links_router, prefix='/api')
app.include_router(mastodon_router, prefix='/api/mastodon')
app.include_router(public_router, prefix='/api/public') app.include_router(public_router, prefix='/api/public')
app.include_router(admin_router, prefix='/api/admin') app.include_router(admin_router, prefix='/api/admin')
app.include_router(user_config_router, prefix='/api/user') app.include_router(user_config_router, prefix='/api/user')
+106
View File
@@ -0,0 +1,106 @@
## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime, timedelta, timezone
from hashlib import sha256
import json
from secrets import token_urlsafe
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from uuid import uuid4
from backend.app.core.config import settings
from backend.app.database import get_connection
def normalize_instance(instance: str) -> str:
value = instance.strip().rstrip('/')
if not value:
raise ValueError('Mastodon instance is required')
return value if '://' in value else f'https://{value}'
def post_form(url: str, values: dict) -> dict:
request = Request(
url,
data=urlencode(values).encode('utf-8'),
headers={'Content-Type': 'application/x-www-form-urlencoded'},
method='POST',
)
with urlopen(request, timeout=10) as response:
return json.loads(response.read().decode('utf-8'))
def start_authorization(user_id: str, instance: str) -> str:
instance = normalize_instance(instance)
redirect_uri = f'{settings.public_url}/api/mastodon/oauth/callback'
app = post_form(f'{instance}/api/v1/apps', {
'client_name': settings.mastodon_client_name,
'redirect_uris': redirect_uri,
'scopes': 'read:accounts write:statuses',
'website': settings.public_url,
})
state = token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.mastodon_oauth_expiry_minutes)
with get_connection() as conn:
conn.execute('DELETE FROM mastodon_oauth_states WHERE user_id = ?', (user_id,))
conn.execute(
'''INSERT INTO mastodon_oauth_states
(id, user_id, state_hash, instance, client_id, client_secret, redirect_uri, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
(str(uuid4()), user_id, sha256(state.encode()).hexdigest(), instance,
app['client_id'], app['client_secret'], redirect_uri, expires_at.isoformat()),
)
conn.commit()
return f'{instance}/oauth/authorize?' + urlencode({
'client_id': app['client_id'],
'redirect_uri': redirect_uri,
'response_type': 'code',
'scope': 'read:accounts write:statuses',
'state': state,
})
def finish_authorization(code: str, state: str) -> str:
now = datetime.now(timezone.utc).isoformat()
with get_connection() as conn:
record = conn.execute(
'''SELECT * FROM mastodon_oauth_states
WHERE state_hash = ? AND expires_at > ?''',
(sha256(state.encode()).hexdigest(), now),
).fetchone()
if record is None:
raise ValueError('OAuth state is invalid or expired')
conn.execute('DELETE FROM mastodon_oauth_states WHERE id = ?', (record['id'],))
conn.commit()
token = post_form(f"{record['instance']}/oauth/token", {
'grant_type': 'authorization_code',
'code': code,
'client_id': record['client_id'],
'client_secret': record['client_secret'],
'redirect_uri': record['redirect_uri'],
})
access_token = token.get('access_token')
if not access_token:
raise ValueError('Mastodon did not return an access token')
with get_connection() as conn:
current = conn.execute(
'SELECT config FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?',
(record['user_id'], 'mastodon'),
).fetchone()
config = json.loads(current['config']) if current and current['config'] else {}
config.update({'instance': record['instance'], 'access_token': access_token})
if current:
conn.execute(
'UPDATE user_plugin_config SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND plugin_name = ?',
(json.dumps(config), record['user_id'], 'mastodon'),
)
else:
conn.execute(
'''INSERT INTO user_plugin_config
(id, user_id, plugin_name, config, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)''',
(str(uuid4()), record['user_id'], 'mastodon', json.dumps(config)),
)
conn.commit()
return record['user_id']
+2 -2
View File
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
connection = sqlite3.connect(':memory:') connection = sqlite3.connect(':memory:')
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 7 assert get_schema_version(connection) == 8
tables = { tables = {
row[0] row[0]
for row in connection.execute( for row in connection.execute(
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
assert set(DEFAULT_TAGS) <= seeded_tags assert set(DEFAULT_TAGS) <= seeded_tags
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 7 assert get_schema_version(connection) == 8
connection.close() connection.close()
+2
View File
@@ -23,6 +23,8 @@ services:
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true} LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24} LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1} LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog}
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10}
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
restart: ${APP_RESTART_POLICY:-unless-stopped} restart: ${APP_RESTART_POLICY:-unless-stopped}
healthcheck: healthcheck:
+2
View File
@@ -26,6 +26,8 @@ services:
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true} LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24} LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1} LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
LINKLOG_MASTODON_CLIENT_NAME: ${LINKLOG_MASTODON_CLIENT_NAME:-LinkLog}
LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES: ${LINKLOG_MASTODON_OAUTH_EXPIRY_MINUTES:-10}
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-} LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
restart: ${APP_RESTART_POLICY:-unless-stopped} restart: ${APP_RESTART_POLICY:-unless-stopped}
healthcheck: healthcheck:
+19 -1
View File
@@ -9,6 +9,7 @@ const profileLogoutButton = document.querySelector('#logout-button');
const accessToken = localStorage.getItem('linklogAccessToken'); const accessToken = localStorage.getItem('linklogAccessToken');
const defaultMastodonInstance = 'mastodon.social'; const defaultMastodonInstance = 'mastodon.social';
const defaultPostPrefix = 'From my #LinkLog: '; const defaultPostPrefix = 'From my #LinkLog: ';
const mastodonConnectButton = document.querySelector('#mastodon-connect');
function authHeaders(includeJson = false) { function authHeaders(includeJson = false) {
return { return {
@@ -46,7 +47,6 @@ async function loadMastodonConfig() {
if (!response.ok) throw new Error('Could not load Mastodon settings'); if (!response.ok) throw new Error('Could not load Mastodon settings');
const config = await response.json(); const config = await response.json();
document.querySelector('#mastodon-instance').value = config.instance || defaultMastodonInstance; document.querySelector('#mastodon-instance').value = config.instance || defaultMastodonInstance;
document.querySelector('#mastodon-access-token').value = config.access_token || '';
document.querySelector('#mastodon-post-prefix').value = config.post_prefix || defaultPostPrefix; document.querySelector('#mastodon-post-prefix').value = config.post_prefix || defaultPostPrefix;
} }
@@ -94,6 +94,24 @@ mastodonForm.addEventListener('submit', async (event) => {
setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok); setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok);
}); });
mastodonConnectButton.addEventListener('click', async () => {
mastodonConnectButton.disabled = true;
const instance = document.querySelector('#mastodon-instance').value || defaultMastodonInstance;
try {
const response = await fetch(`/api/mastodon/oauth/start?instance=${encodeURIComponent(instance)}`, {headers: authHeaders()});
const result = await response.json();
if (!response.ok || !result.authorization_url) throw new Error(result.error || 'Could not start Mastodon authorization.');
window.location.assign(result.authorization_url);
} catch (error) {
setStatus('#mastodon-status', error.message, true);
mastodonConnectButton.disabled = false;
}
});
const mastodonParams = new URLSearchParams(window.location.search);
if (mastodonParams.get('mastodon') === 'connected') setStatus('#mastodon-status', 'Mastodon connected.');
if (mastodonParams.get('mastodon_error')) setStatus('#mastodon-status', mastodonParams.get('mastodon_error'), true);
passwordForm.addEventListener('submit', async (event) => { passwordForm.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const response = await fetch('/api/user/password', { const response = await fetch('/api/user/password', {
+2 -5
View File
@@ -84,14 +84,11 @@
Instance Instance
<input id="mastodon-instance" name="instance" type="text" value="mastodon.social" placeholder="mastodon.social" /> <input id="mastodon-instance" name="instance" type="text" value="mastodon.social" placeholder="mastodon.social" />
</label> </label>
<label>
Access token
<input id="mastodon-access-token" name="access_token" type="password" autocomplete="off" />
</label>
<label> <label>
Post prefix Post prefix
<input id="mastodon-post-prefix" name="post_prefix" type="text" value="From my #LinkLog: " /> <input id="mastodon-post-prefix" name="post_prefix" type="text" value="From my #LinkLog: " />
</label> </label>
<button id="mastodon-connect" type="button">Connect Mastodon</button>
<button type="submit">Save Mastodon settings</button> <button type="submit">Save Mastodon settings</button>
<p id="mastodon-status" class="status" role="status"></p> <p id="mastodon-status" class="status" role="status"></p>
</form> </form>
@@ -100,6 +97,6 @@
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer> <footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=2"></script> <script src="/static/logout.js?v=2"></script>
<script src="/static/profile.js?v=3"></script> <script src="/static/profile.js?v=4"></script>
</body> </body>
</html> </html>