package main import ( "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "inou/lib" ) // OAuth 2.0 Authorization Server Implementation // Supports: Authorization Code flow with PKCE (for Flutter/mobile) // // Endpoints: // GET /oauth/authorize - Authorization endpoint (shows consent, redirects with code) // POST /oauth/token - Token endpoint (exchanges code for tokens, refreshes) // GET /oauth/userinfo - UserInfo endpoint (returns user profile) // POST /oauth/revoke - Revoke refresh token // // Token Strategy: // - Access tokens: 15 minutes, stateless (encrypted blob) // - Refresh tokens: 30 days, DB-stored, rotated on each use const ( accessTokenDuration = 15 * time.Minute refreshTokenDuration = 30 * 24 * time.Hour ) // oauthError returns an OAuth 2.0 compliant error response func oauthError(w http.ResponseWriter, err, desc string, code int) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{ "error": err, "error_description": desc, }) } // oauthJSON returns a JSON response with no-cache headers func oauthJSON(w http.ResponseWriter, data any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(data) } // handleOAuthAuthorize handles GET /oauth/authorize // Parameters: client_id, redirect_uri, response_type, state, code_challenge, code_challenge_method func handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { oauthError(w, "invalid_request", "Method must be GET", http.StatusMethodNotAllowed) return } // Parse parameters clientID := r.URL.Query().Get("client_id") redirectURI := r.URL.Query().Get("redirect_uri") responseType := r.URL.Query().Get("response_type") state := r.URL.Query().Get("state") codeChallenge := r.URL.Query().Get("code_challenge") codeChallengeMethod := r.URL.Query().Get("code_challenge_method") // Validate required parameters if clientID == "" { oauthError(w, "invalid_request", "client_id is required", http.StatusBadRequest) return } if redirectURI == "" { oauthError(w, "invalid_request", "redirect_uri is required", http.StatusBadRequest) return } if responseType != "code" { oauthError(w, "unsupported_response_type", "Only 'code' response type is supported", http.StatusBadRequest) return } // Validate client client, err := lib.OAuthClientGet(clientID) if err != nil { oauthError(w, "invalid_client", "Unknown client_id", http.StatusBadRequest) return } // Validate redirect URI if !lib.OAuthClientValidRedirectURI(client, redirectURI) { oauthError(w, "invalid_request", "Invalid redirect_uri for this client", http.StatusBadRequest) return } // Validate PKCE if provided if codeChallenge != "" && codeChallengeMethod != "S256" { oauthError(w, "invalid_request", "Only S256 code_challenge_method is supported", http.StatusBadRequest) return } // Check if user is logged in dossier := getLoggedInDossier(r) if dossier == nil { // Store return URL in cookie and redirect to login returnURL := r.URL.String() http.SetCookie(w, &http.Cookie{ Name: "oauth_return", Value: returnURL, Path: "/", MaxAge: 600, // 10 minutes HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) http.Redirect(w, r, "/start", http.StatusSeeOther) return } // User is logged in - generate authorization code code, err := lib.OAuthCodeCreate( clientID, dossier.DossierID, redirectURI, codeChallenge, codeChallengeMethod, ) if err != nil { oauthError(w, "server_error", "Failed to create authorization code", http.StatusInternalServerError) return } // Build redirect URL with code redirectURL, _ := url.Parse(redirectURI) q := redirectURL.Query() q.Set("code", code.Code) if state != "" { q.Set("state", state) } redirectURL.RawQuery = q.Encode() http.Redirect(w, r, redirectURL.String(), http.StatusSeeOther) } // handleOAuthToken handles POST /oauth/token // Supports: authorization_code, refresh_token grant types func handleOAuthToken(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { oauthError(w, "invalid_request", "Method must be POST", http.StatusMethodNotAllowed) return } // Parse form or JSON body contentType := r.Header.Get("Content-Type") var grantType, code, redirectURI, clientID, clientSecret, refreshToken, codeVerifier string if strings.Contains(contentType, "application/json") { var body map[string]string if err := json.NewDecoder(r.Body).Decode(&body); err != nil { oauthError(w, "invalid_request", "Invalid JSON body", http.StatusBadRequest) return } grantType = body["grant_type"] code = body["code"] redirectURI = body["redirect_uri"] clientID = body["client_id"] clientSecret = body["client_secret"] refreshToken = body["refresh_token"] codeVerifier = body["code_verifier"] } else { r.ParseForm() grantType = r.FormValue("grant_type") code = r.FormValue("code") redirectURI = r.FormValue("redirect_uri") clientID = r.FormValue("client_id") clientSecret = r.FormValue("client_secret") refreshToken = r.FormValue("refresh_token") codeVerifier = r.FormValue("code_verifier") } switch grantType { case "authorization_code": handleAuthorizationCodeGrant(w, clientID, clientSecret, code, redirectURI, codeVerifier) case "refresh_token": handleRefreshTokenGrant(w, clientID, clientSecret, refreshToken) default: oauthError(w, "unsupported_grant_type", "Only authorization_code and refresh_token grants are supported", http.StatusBadRequest) } } // handleAuthorizationCodeGrant exchanges an authorization code for tokens func handleAuthorizationCodeGrant(w http.ResponseWriter, clientID, clientSecret, code, redirectURI, codeVerifier string) { // Validate client client, err := lib.OAuthClientGet(clientID) if err != nil { oauthError(w, "invalid_client", "Unknown client_id", http.StatusUnauthorized) return } // Verify client secret (if provided - public clients may not have one) if clientSecret != "" && !lib.OAuthClientVerifySecret(client, clientSecret) { oauthError(w, "invalid_client", "Invalid client_secret", http.StatusUnauthorized) return } // Get and validate code authCode, err := lib.OAuthCodeGet(code) if err != nil { oauthError(w, "invalid_grant", "Invalid or expired authorization code", http.StatusBadRequest) return } // Verify code belongs to this client if authCode.ClientID != clientID { oauthError(w, "invalid_grant", "Code was not issued to this client", http.StatusBadRequest) return } // Verify redirect URI matches if authCode.RedirectURI != redirectURI { oauthError(w, "invalid_grant", "redirect_uri mismatch", http.StatusBadRequest) return } // Verify PKCE if !lib.OAuthCodeVerifyPKCE(authCode, codeVerifier) { oauthError(w, "invalid_grant", "Invalid code_verifier", http.StatusBadRequest) return } // Mark code as used if err := lib.OAuthCodeUse(code); err != nil { oauthError(w, "server_error", "Failed to consume code", http.StatusInternalServerError) return } // Generate tokens accessToken := lib.TokenCreate(authCode.DossierID, accessTokenDuration) refreshTokenObj, err := lib.OAuthRefreshTokenCreate(clientID, authCode.DossierID) if err != nil { oauthError(w, "server_error", "Failed to create refresh token", http.StatusInternalServerError) return } oauthJSON(w, map[string]any{ "access_token": accessToken, "token_type": "Bearer", "expires_in": int(accessTokenDuration.Seconds()), "refresh_token": refreshTokenObj.TokenID, }) } // handleRefreshTokenGrant exchanges a refresh token for new tokens func handleRefreshTokenGrant(w http.ResponseWriter, clientID, clientSecret, refreshToken string) { // Validate client client, err := lib.OAuthClientGet(clientID) if err != nil { oauthError(w, "invalid_client", "Unknown client_id", http.StatusUnauthorized) return } // Verify client secret (if provided) if clientSecret != "" && !lib.OAuthClientVerifySecret(client, clientSecret) { oauthError(w, "invalid_client", "Invalid client_secret", http.StatusUnauthorized) return } // Get and validate refresh token oldToken, err := lib.OAuthRefreshTokenGet(refreshToken) if err != nil { oauthError(w, "invalid_grant", "Invalid or expired refresh token", http.StatusBadRequest) return } // Verify token belongs to this client if oldToken.ClientID != clientID { oauthError(w, "invalid_grant", "Token was not issued to this client", http.StatusBadRequest) return } // Rotate refresh token (revoke old, create new) newRefreshToken, err := lib.OAuthRefreshTokenRotate(refreshToken) if err != nil { oauthError(w, "server_error", "Failed to rotate refresh token", http.StatusInternalServerError) return } // Generate new access token accessToken := lib.TokenCreate(oldToken.DossierID, accessTokenDuration) oauthJSON(w, map[string]any{ "access_token": accessToken, "token_type": "Bearer", "expires_in": int(accessTokenDuration.Seconds()), "refresh_token": newRefreshToken.TokenID, }) } // handleOAuthUserinfo handles GET /oauth/userinfo // Returns the authenticated user's profile func handleOAuthUserinfo(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { oauthError(w, "invalid_request", "Method must be GET", http.StatusMethodNotAllowed) return } // Get bearer token auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { w.Header().Set("WWW-Authenticate", `Bearer realm="inou"`) oauthError(w, "invalid_token", "Bearer token required", http.StatusUnauthorized) return } tokenStr := strings.TrimPrefix(auth, "Bearer ") token, err := lib.TokenParse(tokenStr) if err != nil { w.Header().Set("WWW-Authenticate", `Bearer realm="inou", error="invalid_token"`) oauthError(w, "invalid_token", "Invalid or expired token", http.StatusUnauthorized) return } // Get dossier dossier, err := lib.DossierGet(nil, token.DossierID) // nil ctx - internal operation if err != nil || dossier == nil { oauthError(w, "invalid_token", "User not found", http.StatusUnauthorized) return } // Return OpenID Connect compatible userinfo oauthJSON(w, map[string]any{ "sub": dossier.DossierID, "name": dossier.Name, "email": dossier.Email, }) } // handleOAuthRevoke handles POST /oauth/revoke // Revokes a refresh token func handleOAuthRevoke(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { oauthError(w, "invalid_request", "Method must be POST", http.StatusMethodNotAllowed) return } r.ParseForm() token := r.FormValue("token") if token == "" { oauthError(w, "invalid_request", "token is required", http.StatusBadRequest) return } // Revoke the token (ignore errors - RFC 7009 says always return 200) lib.OAuthRefreshTokenRevoke(token) w.WriteHeader(http.StatusOK) } // RegisterOAuthRoutes registers OAuth endpoints func RegisterOAuthRoutes(mux *http.ServeMux) { mux.HandleFunc("/oauth/authorize", handleOAuthAuthorize) mux.HandleFunc("/oauth/token", handleOAuthToken) mux.HandleFunc("/oauth/userinfo", handleOAuthUserinfo) mux.HandleFunc("/oauth/revoke", handleOAuthRevoke) } // CreateAnthropicClient creates the OAuth client for Anthropic/Claude // Call this once during setup func CreateAnthropicClient() error { // Check if already exists _, err := lib.OAuthClientGet("anthropic") if err == nil { return nil // Already exists } // Create client with Anthropic's callback URLs redirectURIs := []string{ "https://claude.ai/api/mcp/auth_callback", "https://claude.com/api/mcp/auth_callback", "http://localhost:6274/oauth/callback", "http://localhost:6274/oauth/callback/debug", } client, secret, err := lib.OAuthClientCreate("Anthropic Claude", redirectURIs) if err != nil { return err } fmt.Printf("Created Anthropic OAuth client:\n") fmt.Printf(" Client ID: %s\n", client.ClientID) fmt.Printf(" Client Secret: %s\n", secret) fmt.Printf(" (Save the secret - it cannot be retrieved later)\n") return nil } // EnsureBridgeClient creates the OAuth client for the MCP bridge (public client, no secret) // Called on startup to ensure the client exists func EnsureBridgeClient() error { _, err := lib.OAuthClientGet("inou-bridge") if err == nil { return nil // Already exists } // Create public client (no redirect URIs needed - uses refresh_token grant only) return lib.OAuthClientCreatePublic("inou-bridge", "Inou Bridge") }