Skip to content

feat: add OAuth support for external applications #2251

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

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

m0wer
Copy link
Contributor

@m0wer m0wer commented Jun 27, 2025

Description

This PR implements a complete OAuth 2.0 authorization server, enabling external applications to securely integrate with SN accounts and wallets. The implementation follows RFC 6749 standards, including the Authorization Code Grant with PKCE, providing robust security for both users and client applications.

Closes #2119

Key Features:

  • Full OAuth 2.0 Flow: Implements the Authorization Code grant with PKCE (Proof Key for Code Exchange) for secure authorization.
  • Application Management UI: A new section in user settings (/settings/oauth-applications) allows developers to register, update, and manage their OAuth applications.
  • User Consent Screen: A dedicated page (/oauth/consent) where users can review and approve the permissions requested by an application before granting access.
  • Granular Scopes: Defines a set of scopes for basic user information (read, profile:read) and wallet permissions (wallet:read, wallet:send, wallet:receive).
  • Secure Wallet API: New API endpoints under /api/oauth/wallet/ allow authorized applications to check balances, create invoices, and initiate payments on behalf of the user, without ever accessing their credentials directly.
  • Token Management: Securely issues, validates, and refreshes access tokens and refresh tokens.
  • Rate Limiting: Basic rate-limiting is in place for API endpoints to prevent abuse.
  • Database Integration: Adds all necessary tables to the database via a Prisma migration to manage applications, grants, tokens, and API usage.
  • Service Worker for Notifications: Includes a service worker to handle future push notifications for asynchronous events like payment approvals.

TODO

  • Admin Approval Flow: The approved flag on OAuthApplication is present, but there's no UI or process for an admin to approve new applications. This needs to be built. Or should apps be approved by default? Or is it OK to run a manual DB query?
  • UI/UX Refinements:
    • Maybe the colors could be improved.

Screenshots

consent
client secret
authorized apps

Checklist

Are your changes backward compatible? Please answer below:

Yes, this is completely backward compatible. All new functionality is additive:

  • New API endpoints under the /api/oauth/ namespace.
  • New database tables with no modifications to existing schema relationships.
  • New UI pages (/settings/oauth-applications, /oauth/consent) that don't affect existing functionality.
  • The new service worker is optional and doesn't interfere with existing code.

On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:

TODO

For frontend changes: Tested on mobile, light and dark mode? Please answer below:

TODO

Did you introduce any new environment variables? If so, call them out explicitly here:

No new environment variables are required. The implementation uses existing variables:

  • NEXTAUTH_URL for constructing OAuth redirect URLs.

Copy link

socket-security bot commented Jun 27, 2025

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedbcryptjs@​3.0.210010010080100

View full report

@m0wer
Copy link
Contributor Author

m0wer commented Jun 27, 2025

I'm using this small script to test:

import http.server
import socketserver
import urllib.parse
import webbrowser
import requests
import base64
import hashlib
import os

# --- Configuration ---
CLIENT_ID = "69d418adf758cd8c53920fdd3514cde2f1070d4325c9b07aa11efab7813a8e97"
CLIENT_SECRET = "a0ce7042b260705697620a79260e7ad345730c53e89157cbb243dae401a54aba"
REDIRECT_URI = "http://localhost:5000/callback"
AUTHORIZATION_URL = "http://localhost:3000/api/oauth/authorize"
TOKEN_URL = "http://localhost:3000/api/oauth/token"
SCOPES = "wallet:read profile:read" # Space-separated list of scopes

# --- PKCE Functions ---
def generate_code_verifier():
    return base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode('utf-8')

def generate_code_challenge(code_verifier):
    s256 = hashlib.sha256(code_verifier.encode('utf-8')).digest()
    return base64.urlsafe_b64encode(s256).rstrip(b'=').decode('utf-8')

# --- Global variables to store the code and verifier ---
authorization_code = None
pkce_code_verifier = generate_code_verifier()
pkce_code_challenge = generate_code_challenge(pkce_code_verifier)

class OAuthCallbackHandler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        global authorization_code
        parsed_url = urllib.parse.urlparse(self.path)
        query_params = urllib.parse.parse_qs(parsed_url.query)

        if parsed_url.path == "/callback" and "code" in query_params:
            authorization_code = query_params["code"][0]
            self.send_response(200)
            self.send_header("Content-type", "text/html")
            self.end_headers()
            self.wfile.write(b"<html><body><h1>Authorization successful!</h1><p>You can close this tab.</p></body></html>")
            print(f"Received authorization code: {authorization_code}")
        else:
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b"Not Found")

def start_local_server():
    PORT = 5000
    with socketserver.TCPServer(("", PORT), OAuthCallbackHandler) as httpd:
        print(f"Serving at port {PORT} to catch OAuth redirect...")
        httpd.handle_request()

def main():
    print("--- Starting OAuth 2.0 Authorization Code Flow with PKCE ---")

    # 1. Construct Authorization URL
    auth_params = {
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "response_type": "code",
        "scope": SCOPES,
        "code_challenge": pkce_code_challenge,
        "code_challenge_method": "S256"
    }
    auth_query_string = urllib.parse.urlencode(auth_params)
    full_authorization_url = f"{AUTHORIZATION_URL}?{auth_query_string}"

    print(f"\nOpening authorization URL in your browser. Please approve the request:")
    print(full_authorization_url)
    webbrowser.open(full_authorization_url)

    # 2. Start local server to catch the redirect
    start_local_server()

    if not authorization_code:
        print("Error: Did not receive an authorization code.")
        return

    # 3. Exchange Authorization Code for Access Token
    print("\nExchanging authorization code for access token...")
    token_data = {
        "grant_type": "authorization_code",
        "code": authorization_code,
        "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
        "code_verifier": pkce_code_verifier
    }

    try:
        response = requests.post(TOKEN_URL, data=token_data)
        response.raise_for_status() # Raise an exception for HTTP errors
        token_info = response.json()

        print("\n--- Access Token Response ---")
        print(f"Access Token: {token_info.get('access_token')}")
        print(f"Token Type: {token_info.get('token_type')}")
        print(f"Expires In: {token_info.get('expires_in')} seconds")
        print(f"Refresh Token: {token_info.get('refresh_token')}")
        print(f"Scope: {token_info.get('scope')}")

    except requests.exceptions.HTTPError as e:
        print(f"HTTP Error during token exchange: {e}")
        print(f"Response content: {e.response.text}")
    except Exception as e:
        print(f"An error occurred: {e}")
        return

    # 4. Use the Access Token to make an API call
    print("\n--- Making API Call ---")
    api_url = "http://localhost:3000/api/oauth/wallet/balance"
    headers = {
        "Authorization": f"Bearer {token_info.get('access_token')}"
    }
    try:
        api_response = requests.get(api_url, headers=headers)
        api_response.raise_for_status()
        api_data = api_response.json()
        print("API call successful!")
        print("Response:")
        print(api_data)
    except requests.exceptions.HTTPError as e:
        print(f"HTTP Error during API call: {e}")
        print(f"Response content: {e.response.text}")
    except Exception as e:
        print(f"An error occurred during API call: {e}")


if __name__ == "__main__":
    main()
--- Starting OAuth 2.0 Authorization Code Flow with PKCE ---

Opening authorization URL in your browser. Please approve the request:
http://localhost:3000/api/oauth/authorize?client_id=69d418adf758cd8c53920fdd3514cde2f1070d4325c9b07aa11efab7813a8e97&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Fcallback&response_type=code&scope=wallet%3Aread+profile%3Aread&code_challenge=RAlkuGo5FXRljroMbrXnQJO0yJps5I2NyEi0LfOJeLc&code_challenge_method=S256
Serving at port 5000 to catch OAuth redirect...
127.0.0.1 - - [27/Jun/2025 22:59:21] "GET /callback?code=d0376b77f3e1fed733f0f2990e8b1a14afe3f3f5ea3432f13a9b884112a64ec2 HTTP/1.1" 200 -
Received authorization code: d0376b77f3e1fed733f0f2990e8b1a14afe3f3f5ea3432f13a9b884112a64ec2

Exchanging authorization code for access token...

--- Access Token Response ---
Access Token: f80acc2787ef42ba5125395b5a8b67315e263a564669443676e18b833cee41ff
Token Type: Bearer
Expires In: 7200 seconds
Refresh Token: c9069af66cff213de0675b9920b29dbdbbff7c73cdedabe963f8e459b7c5723f
Scope: wallet:read profile:read

--- Making API Call ---
API call successful!
Response:
{'balance_msats': '0', 'balance_sats': 0, 'stacked_msats': '0', 'stacked_sats': 0}

@m0wer
Copy link
Contributor Author

m0wer commented Jun 27, 2025

Maybe the rate limiting should be removed completely. I'm not really sure about how the production environment is deployed. If there are multiple workers of the backend, this rate limiter won't be enough since it is in memory and won't be shared across workers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Login with Stacker News
1 participant