Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OAuth #42

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a0e1e8d
make oauth login form actually verify creds
DavidBuchanan314 Jan 17, 2025
769a828
create session cookie on successful auth
DavidBuchanan314 Jan 17, 2025
1bc99b8
new table to track oauth grants
DavidBuchanan314 Jan 17, 2025
9a2bb78
split authz and authn into dedicated routes
DavidBuchanan314 Jan 17, 2025
9f68f9b
actually store PARs
DavidBuchanan314 Jan 17, 2025
4886ff1
pass 'next' parameter to authentication endpoint, use it to redirect …
DavidBuchanan314 Jan 17, 2025
e82f695
retreive PAR, client_id, list authz scopes in web ui
DavidBuchanan314 Jan 17, 2025
c82f705
old-python compat
DavidBuchanan314 Jan 17, 2025
991b156
old-python compat, take 2
DavidBuchanan314 Jan 17, 2025
254fa63
fix error icon alignment
DavidBuchanan314 Jan 17, 2025
8376c45
refactoring
DavidBuchanan314 Jan 19, 2025
2961123
hand out some fake tokens just so we can complete the flow
DavidBuchanan314 Jan 19, 2025
32ea4a6
send some real auth tokens
DavidBuchanan314 Jan 19, 2025
7e5de4c
implement jwk fingerprinting for dpop
DavidBuchanan314 Jan 20, 2025
4f02f5f
forbid redirects when fetching client metadata
DavidBuchanan314 Jan 20, 2025
92b4686
refactor dpop header processing into a middleware
DavidBuchanan314 Jan 20, 2025
c880469
refactor authentication into a middleware
DavidBuchanan314 Jan 22, 2025
0d39403
refactor service auth token generation, check for chat.bsky scope
DavidBuchanan314 Jan 22, 2025
ce168c0
fix inbound lxm check
DavidBuchanan314 Jan 22, 2025
53ebf02
fix service proxying
DavidBuchanan314 Jan 22, 2025
e5ba5ce
make scope grant flow work again, remember granted scopes
DavidBuchanan314 Jan 23, 2025
adc9a2b
prevent open redirects after login, clean up server metadata doc
DavidBuchanan314 Jan 23, 2025
9074f36
reuse app password api for oauth scope management
DavidBuchanan314 Jan 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 159 additions & 21 deletions src/millipds/auth_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
import jwt
import cbrrr
import json
import secrets
import time
import urllib.parse

from aiohttp import web

from . import database
from . import html_templates
from .app_util import *
from . import static_config

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -122,27 +126,144 @@ async def oauth_authorization_server(request: web.Request):
# this is where a client will redirect to during the auth flow.
# they'll see a webpage asking them to login
@routes.get("/oauth/authorize")
async def oauth_authorize(request: web.Request):
# TODO: extract request_uri
async def oauth_authorize_get(request: web.Request):
now = int(time.time())
db = get_db(request)

session_token = request.cookies.get("millipds-oauth-session")
row = db.con.execute(
"""
SELECT user_id FROM oauth_session_cookie
WHERE token=? AND expires_at>?
""",
(session_token, now),
).fetchone()
if row is None:
# no active oauth cookie session
return web.HTTPTemporaryRedirect(
"/oauth/authenticate?"
+ urllib.parse.urlencode({"next": request.path_qs})
)

# if we reached here, either there was an existing session, or the user
# just created a new one and got redirected back again

did, handle = db.con.execute(
"SELECT did, handle FROM user WHERE id=?", row
).fetchone()
# TODO: check id hint in auth request matches!

# TODO: look at the requested scopes, see if the user already granted them already,
# display UI as appropriate

return web.Response(
text=html_templates.authn_page(), # this includes a login form that POSTs to /oauth/authorize (i.e. same endpoint)
text=html_templates.authz_page(handle=handle),
content_type="text/html",
headers=WEBUI_HEADERS,
)


# after login, assuming the creds were good, the user will be prompted to
# authorize the client application to access certain scopes
@routes.post("/oauth/authorize")
async def oauth_authorize_handle_login(request: web.Request):
# TODO: actually handle login
async def oauth_authorize_post(request: web.Request):
now = int(time.time())
db = get_db(request)

# TODO: don't duplicate code between here and the GET handler
session_token = request.cookies.get("millipds-oauth-session")
row = db.con.execute(
"""
SELECT user_id FROM oauth_session_cookie
WHERE token=? AND expires_at>?
""",
(session_token, now),
).fetchone()
if row is None:
# no active oauth cookie session
# this should be pretty rare - session expired between the initial GET and the form submission
return web.HTTPTemporaryRedirect(
"/oauth/authenticate?"
+ urllib.parse.urlencode({"next": request.path_qs})
)

# TODO: redirect back to app?
return web.Response(
text=html_templates.authz_page(),
text="TODO",
content_type="text/html",
headers=WEBUI_HEADERS,
)


@routes.get("/oauth/authenticate")
async def oauth_authenticate_get(request: web.Request):
return web.Response(
text=html_templates.authn_page(
identifier_hint="todo.invalid"
), # this includes a login form that POSTs to the same endpoint
content_type="text/html",
headers=WEBUI_HEADERS,
)


@routes.post("/oauth/authenticate")
async def oauth_authenticate_post(request: web.Request):
form = await request.post()
logging.info(form)

db = get_db(request)
form_identifier = form.get("identifier", "")
form_password = form.get("password", "")

try:
user_id, did, handle = db.verify_account_login(
form_identifier, form_password
)
# login succeeded, let's start a new cookie session
session_token = secrets.token_hex()
session_value = {}
now = int(time.time())
db.con.execute(
"""
INSERT INTO oauth_session_cookie (
token, user_id, value, created_at, expires_at
) VALUES (?, ?, ?, ?, ?)
""",
(
session_token,
user_id,
cbrrr.encode_dag_cbor(session_value),
now,
now + static_config.OAUTH_COOKIE_EXP,
),
)
# we can't use a 301/302 redirect because we need to produce a GET
next = request.query.get("next", "")
# TODO: !!!important!!! assert next is a relative URL, or absolutify it somehow
DavidBuchanan314 marked this conversation as resolved.
Show resolved Hide resolved
res = web.Response(
text=html_templates.redirect(next),
content_type="text/html",
headers=WEBUI_HEADERS,
)
res.set_cookie(
name="millipds-oauth-session",
value=session_token,
max_age=static_config.OAUTH_COOKIE_EXP,
path="/oauth/authorize", # the only page that needs to see it
secure=True, # prevents token from leaking over plaintext channels
httponly=True, # prevents XSS from being able to steal tokens
samesite="Strict", # mitigates CSRF
)
return res
except:
return web.Response(
text=html_templates.authn_page(
identifier_hint=form_identifier,
error_msg="incorrect identifier or password",
),
content_type="text/html",
headers=WEBUI_HEADERS,
)


DPOP_NONCE = "placeholder_nonce_value" # this needs to get rotated! (does it matter that it's global?)


Expand All @@ -158,9 +279,9 @@ async def dpop_handler(request: web.Request):
)
jwk_data = unverified["header"]["jwk"]
jwk = jwt.PyJWK.from_dict(jwk_data)
decoded: dict = jwt.decode(
dpop, key=jwk
) # actual signature verification happens here

# actual signature verification happens here:
decoded: dict = jwt.decode(dpop, key=jwk)

logger.info(decoded)
logger.info(request.url)
Expand Down Expand Up @@ -210,18 +331,35 @@ async def dpop_handler(request: web.Request):
@routes.post("/oauth/par")
@dpop_protected
async def oauth_pushed_authorization_request(request: web.Request):
data = (
await request.json()
) # TODO: doesn't rfc9126 say it's posted as form data?
logging.info(data)

assert data["client_id"] == request["dpop_iss"] # idk if this is required

# we need to store the request somewhere, and associate it with the URI we return
# NOTE: rfc9126 says this is posted as form data, but this is atproto-flavoured oauth
request_json = await request.json()
logging.info(request_json)

# idk if this is required
assert request_json["client_id"] == request["dpop_iss"]

now = int(time.time())
par_uri = "urn:ietf:params:oauth:request_uri:req-" + secrets.token_hex()

# NOTE: we don't do any verification of the auth request itself, we just associate it with a URI for later retreival.
get_db(request).con.execute(
"""
INSERT INTO oauth_par (
uri, dpop_jwk, value, created_at, expires_at
) VALUES (?, ?, ?, ?, ?)
""",
(
par_uri,
request["dpop_jwk"],
cbrrr.encode_dag_cbor(request_json),
now,
now + static_config.OAUTH_PAR_EXP,
),
)

return web.json_response(
{
"request_uri": "urn:ietf:params:oauth:request_uri:req-064ed63e9fbf10815fd5f402f4f3e92a", # XXX hardcoded test
"expires_in": 299,
"request_uri": par_uri,
"expires_in": static_config.OAUTH_PAR_EXP,
}
)
51 changes: 47 additions & 4 deletions src/millipds/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ def _init_tables(self):

# this is only for the tokens *we* issue, dpop jti will be tracked separately
# there's no point remembering that an expired token was revoked, and we'll garbage-collect these periodically
# note: I'm using did here instead of user_id, this is vaguely inconsistent
# with other tables but in practice it should reduce query complexity
self.con.execute(
"""
CREATE TABLE revoked_token(
Expand All @@ -259,6 +261,47 @@ def _init_tables(self):
"""
)

# oauth stuff!
self.con.execute(
"""
CREATE TABLE oauth_session_cookie(
token TEXT PRIMARY KEY NOT NULL,
user_id INTEGER NOT NULL,
value BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id)
) STRICT, WITHOUT ROWID
"""
)

# TODO: unsure if we need to track dpop jwk here?
# (if we do, it could just be a hash of the key)
self.con.execute(
"""
CREATE TABLE oauth_par(
uri TEXT PRIMARY KEY NOT NULL,
dpop_jwk BLOB NOT NULL,
value BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
) STRICT, WITHOUT ROWID
"""
)

# has user granted a particular scope to a particular app?
self.con.execute(
"""
CREATE TABLE oauth_grants(
user_id INTEGER NOT NULL,
client_id TEXT NOT NULL,
scope TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES user(id),
PRIMARY KEY (user_id, client_id, scope)
) STRICT, WITHOUT ROWID
"""
)

def update_config(
self,
pds_pfx: Optional[str] = None,
Expand Down Expand Up @@ -373,19 +416,19 @@ def create_account(

def verify_account_login(
self, did_or_handle: str, password: str
) -> Tuple[str, str]:
) -> Tuple[int, str, str]:
row = self.con.execute(
"SELECT did, handle, pw_hash FROM user WHERE did=? OR handle=?",
"SELECT id, did, handle, pw_hash FROM user WHERE did=? OR handle=?",
(did_or_handle, did_or_handle),
).fetchone()
if row is None:
raise KeyError("no account found for did")
did, handle, pw_hash = row
user_id, did, handle, pw_hash = row
try:
self.pw_hasher.verify(pw_hash, password)
except argon2.exceptions.VerifyMismatchError:
raise ValueError("invalid password")
return did, handle
return user_id, did, handle

def did_by_handle(self, handle: str) -> Optional[str]:
row = self.con.execute(
Expand Down
Loading
Loading